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
package/src/validate.ts
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance, and nothing that reading bytes depends on.
|
|
3
|
+
*
|
|
4
|
+
* Layer 7, published as `edfcore/validate`. The split is one line: does the check affect BYTE
|
|
5
|
+
* OFFSETS? If it does, it is core and always on, because correctness is the product and cannot be
|
|
6
|
+
* an optional install. If it only tells you the file is impolite, it lives here — a full-file
|
|
7
|
+
* conformance sweep has no business on the open path.
|
|
8
|
+
*
|
|
9
|
+
* Everything in this module is therefore a re-check: nothing it reports changes how a single byte
|
|
10
|
+
* is interpreted, and a caller who never imports it reads exactly the same samples. Some codes
|
|
11
|
+
* overlap with ones the parser already emits (`RECORD_SIZE_ABOVE_RECOMMENDED`,
|
|
12
|
+
* `PATIENT_ID_NONCONFORMANT`); that is deliberate, so a validation report stands on its own
|
|
13
|
+
* instead of only making sense next to `header.diagnostics`.
|
|
14
|
+
*
|
|
15
|
+
* Four codes here are not in the core vocabulary — `LABEL_CONVENTION_NONCONFORMANT`,
|
|
16
|
+
* `PREFILTERING_NONCONFORMANT`, `TRANSDUCER_TYPE_BLANK` and `DATE_IMPLAUSIBLE`. `EdfDiagnosticCode`
|
|
17
|
+
* is an open union for exactly this: they are recommendations from EDF+ additional specification
|
|
18
|
+
* 9, they can never be fatal, and a consumer's `switch` keeps its `default` branch.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { trimEdfField } from './bytes/latin1.js';
|
|
22
|
+
import { EDF_RECOMMENDED_MAX_RECORD_BYTES } from './constants.js';
|
|
23
|
+
import { decodeDigitalCounted } from './decode/digital.js';
|
|
24
|
+
import { createDiagnostic } from './diagnostics/collector.js';
|
|
25
|
+
import { calendarDatesEqual, formatCalendarDate, isValidCalendarDate } from './header/dates.js';
|
|
26
|
+
import { signalFieldOffset } from './header/signals.js';
|
|
27
|
+
import { readRecordBytes } from './io/read.js';
|
|
28
|
+
import { scanChunkRecords } from './record-index.js';
|
|
29
|
+
import { decodeAnnotations } from './tal/annotations.js';
|
|
30
|
+
import { buildSegmentation } from './time/segments.js';
|
|
31
|
+
import { assertMonotonicOnsetArray } from './time/timeline.js';
|
|
32
|
+
import type {
|
|
33
|
+
EdfCalendarDate,
|
|
34
|
+
EdfDiagnostic,
|
|
35
|
+
EdfGap,
|
|
36
|
+
EdfHeader,
|
|
37
|
+
EdfRecordIndex,
|
|
38
|
+
EdfRecording,
|
|
39
|
+
EdfSignal,
|
|
40
|
+
ObservedSignalStats,
|
|
41
|
+
RecordRange,
|
|
42
|
+
ValidateOptions,
|
|
43
|
+
ValidationReport,
|
|
44
|
+
} from './types.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The three shapes only this subpath produces, re-exported from where they are declared.
|
|
48
|
+
*
|
|
49
|
+
* They live in `types.ts` with everything else — one file holds every public data shape — but a
|
|
50
|
+
* consumer of `edfcore/validate` must be able to name a `ValidationReport` without reaching into
|
|
51
|
+
* the universal entry for it.
|
|
52
|
+
*/
|
|
53
|
+
export type { ObservedSignalStats, ValidateOptions, ValidationReport } from './types.js';
|
|
54
|
+
|
|
55
|
+
const LABEL_SPEC = 'EDF+ additional specification 9 (standard texts and labels)';
|
|
56
|
+
const TIMEKEEPING_SPEC = 'EDF+ specification 2.2.1 (time keeping of data records)';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The signal types EDF+ additional specification 9 names.
|
|
60
|
+
*
|
|
61
|
+
* Case-sensitive, and a recommendation rather than a rule: a label outside this list is readable,
|
|
62
|
+
* decodable and perfectly common. edfcore has no montage vocabulary and never infers a channel
|
|
63
|
+
* type from a label — this list is used to report a deviation, never to classify anything.
|
|
64
|
+
*/
|
|
65
|
+
const STANDARD_LABEL_TYPES: ReadonlySet<string> = new Set([
|
|
66
|
+
'EEG',
|
|
67
|
+
'ECG',
|
|
68
|
+
'EOG',
|
|
69
|
+
'ERG',
|
|
70
|
+
'EMG',
|
|
71
|
+
'MEG',
|
|
72
|
+
'MCG',
|
|
73
|
+
'EP',
|
|
74
|
+
'Temp',
|
|
75
|
+
'Resp',
|
|
76
|
+
'SaO2',
|
|
77
|
+
'Light',
|
|
78
|
+
'Sound',
|
|
79
|
+
'Event',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** `HP:0.1Hz LP:75Hz N:50Hz` — high pass, low pass, notch, gain. */
|
|
83
|
+
const PREFILTERING_PREFIXES: readonly string[] = ['HP:', 'LP:', 'N:', 'G:'];
|
|
84
|
+
|
|
85
|
+
/** The spellings of "no filtering" that EDF+ and real writers use interchangeably. */
|
|
86
|
+
const PREFILTERING_NONE: ReadonlySet<string> = new Set(['None', 'none', 'NONE', 'No filtering']);
|
|
87
|
+
|
|
88
|
+
function labelOffset(header: EdfHeader, signal: EdfSignal): number {
|
|
89
|
+
return signalFieldOffset('label', header.signals.length, signal.index);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkRecordSize(header: EdfHeader, into: EdfDiagnostic[]): void {
|
|
93
|
+
if (header.recordByteLength <= EDF_RECOMMENDED_MAX_RECORD_BYTES) return;
|
|
94
|
+
into.push(
|
|
95
|
+
createDiagnostic({
|
|
96
|
+
code: 'RECORD_SIZE_ABOVE_RECOMMENDED',
|
|
97
|
+
message:
|
|
98
|
+
`a data record is ${header.recordByteLength} bytes, above the ` +
|
|
99
|
+
`${EDF_RECOMMENDED_MAX_RECORD_BYTES}-byte record size the EDF specification recommends. ` +
|
|
100
|
+
'EDF specification, data records (recommended maximum record size). Next: nothing — the ' +
|
|
101
|
+
'file reads normally, but every read is record-aligned, so this is also the smallest ' +
|
|
102
|
+
'amount of data any read of this file can return.',
|
|
103
|
+
field: 'recordByteLength',
|
|
104
|
+
expected: `at most ${EDF_RECOMMENDED_MAX_RECORD_BYTES} bytes`,
|
|
105
|
+
actual: `${header.recordByteLength} bytes`,
|
|
106
|
+
specReference: 'EDF specification, data records',
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function checkLabelConvention(header: EdfHeader, signal: EdfSignal, into: EdfDiagnostic[]): void {
|
|
112
|
+
const [type] = signal.label.split(' ');
|
|
113
|
+
if (type !== undefined && STANDARD_LABEL_TYPES.has(type) && signal.label.length > type.length) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
into.push(
|
|
117
|
+
createDiagnostic({
|
|
118
|
+
code: 'LABEL_CONVENTION_NONCONFORMANT',
|
|
119
|
+
message:
|
|
120
|
+
`signal ${signal.index} is labelled ${JSON.stringify(signal.label)}, which is not the ` +
|
|
121
|
+
'EDF+ form "<type> <sensor>" such as "EEG Fpz-Cz" with the type taken from ' +
|
|
122
|
+
`${[...STANDARD_LABEL_TYPES].join(', ')}. ${LABEL_SPEC}. Next: nothing is affected — ` +
|
|
123
|
+
'edfcore never infers a channel type from a label, and getSignal(header, label) matches ' +
|
|
124
|
+
'the trimmed text exactly as written.',
|
|
125
|
+
field: 'label',
|
|
126
|
+
byteOffset: labelOffset(header, signal),
|
|
127
|
+
byteLength: 16,
|
|
128
|
+
raw: signal.raw.label,
|
|
129
|
+
expected: '"<type> <sensor>", e.g. "EEG Fpz-Cz"',
|
|
130
|
+
actual: signal.label,
|
|
131
|
+
signalIndex: signal.index,
|
|
132
|
+
specReference: LABEL_SPEC,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function checkPrefiltering(header: EdfHeader, signal: EdfSignal, into: EdfDiagnostic[]): void {
|
|
138
|
+
const text = signal.prefiltering;
|
|
139
|
+
if (text.length === 0 || PREFILTERING_NONE.has(text)) return;
|
|
140
|
+
const tokens = text.split(/\s+/).filter((token) => token.length > 0);
|
|
141
|
+
const conformant = tokens.every((token) =>
|
|
142
|
+
PREFILTERING_PREFIXES.some((prefix) => token.startsWith(prefix)),
|
|
143
|
+
);
|
|
144
|
+
if (conformant) return;
|
|
145
|
+
|
|
146
|
+
into.push(
|
|
147
|
+
createDiagnostic({
|
|
148
|
+
code: 'PREFILTERING_NONCONFORMANT',
|
|
149
|
+
message:
|
|
150
|
+
`the prefiltering field of signal ${signal.index} (${JSON.stringify(signal.label)}) is ` +
|
|
151
|
+
`${JSON.stringify(text)}, which is not written as space-separated ` +
|
|
152
|
+
`${PREFILTERING_PREFIXES.join(' ')} terms such as "HP:0.1Hz LP:75Hz N:50Hz". ` +
|
|
153
|
+
`${LABEL_SPEC}. Next: nothing is affected — signal.prefiltering keeps the text exactly ` +
|
|
154
|
+
'as written, and edfcore never parses a filter setting out of it.',
|
|
155
|
+
field: 'prefiltering',
|
|
156
|
+
byteOffset: signalFieldOffset('prefiltering', header.signals.length, signal.index),
|
|
157
|
+
byteLength: 80,
|
|
158
|
+
raw: signal.raw.prefiltering,
|
|
159
|
+
expected: 'HP:/LP:/N:/G: terms, or "None"',
|
|
160
|
+
actual: text,
|
|
161
|
+
signalIndex: signal.index,
|
|
162
|
+
specReference: LABEL_SPEC,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function checkTransducer(header: EdfHeader, signal: EdfSignal, into: EdfDiagnostic[]): void {
|
|
168
|
+
if (signal.transducerType.length > 0) return;
|
|
169
|
+
into.push(
|
|
170
|
+
createDiagnostic({
|
|
171
|
+
code: 'TRANSDUCER_TYPE_BLANK',
|
|
172
|
+
message:
|
|
173
|
+
`the transducer type of signal ${signal.index} (${JSON.stringify(signal.label)}) is ` +
|
|
174
|
+
'blank, where EDF+ asks a data signal to name what recorded it, such as "AgAgCl ' +
|
|
175
|
+
`electrode". ${LABEL_SPEC}. Next: nothing is affected — the field is descriptive, and ` +
|
|
176
|
+
'only an annotations signal is required to leave it blank.',
|
|
177
|
+
field: 'transducerType',
|
|
178
|
+
byteOffset: signalFieldOffset('transducerType', header.signals.length, signal.index),
|
|
179
|
+
byteLength: 80,
|
|
180
|
+
raw: signal.raw.transducerType,
|
|
181
|
+
expected: 'a transducer description, e.g. "AgAgCl electrode"',
|
|
182
|
+
actual: '(blank)',
|
|
183
|
+
signalIndex: signal.index,
|
|
184
|
+
specReference: LABEL_SPEC,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function checkIdentification(header: EdfHeader, into: EdfDiagnostic[]): void {
|
|
190
|
+
// Plain EDF puts free text in these fields, which is normal and is not a non-conformance.
|
|
191
|
+
if (!header.variant.includes('+')) return;
|
|
192
|
+
|
|
193
|
+
if (!header.patient.conformant) {
|
|
194
|
+
into.push(
|
|
195
|
+
createDiagnostic({
|
|
196
|
+
code: 'PATIENT_ID_NONCONFORMANT',
|
|
197
|
+
message:
|
|
198
|
+
`local patient identification is ${JSON.stringify(trimEdfField(header.patient.raw))}, ` +
|
|
199
|
+
'which does not follow the EDF+ grammar "code sex(F|M) birthdate(dd-MMM-yyyy) name": ' +
|
|
200
|
+
'four space-separated subfields, X for anything unknown, and no space inside a ' +
|
|
201
|
+
'subfield. EDF+ additional specification 3. Next: header.patient keeps every subfield ' +
|
|
202
|
+
'edfcore could read and the raw text verbatim; nothing about the samples changes.',
|
|
203
|
+
field: 'patientId',
|
|
204
|
+
byteOffset: 8,
|
|
205
|
+
byteLength: 80,
|
|
206
|
+
raw: header.patient.raw,
|
|
207
|
+
expected: 'code sex(F|M) birthdate(dd-MMM-yyyy) name',
|
|
208
|
+
actual: trimEdfField(header.patient.raw),
|
|
209
|
+
specReference: 'EDF+ additional specification 3 (local patient identification)',
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!header.recording.conformant) {
|
|
215
|
+
into.push(
|
|
216
|
+
createDiagnostic({
|
|
217
|
+
code: 'RECORDING_ID_NONCONFORMANT',
|
|
218
|
+
message:
|
|
219
|
+
'local recording identification is ' +
|
|
220
|
+
`${JSON.stringify(trimEdfField(header.recording.raw))}, which does not follow the ` +
|
|
221
|
+
'EDF+ grammar "Startdate startdate(dd-MMM-yyyy) investigationCode technicianCode ' +
|
|
222
|
+
'equipmentCode". EDF+ additional specification 4. Next: header.recording keeps the raw ' +
|
|
223
|
+
'text verbatim; a Startdate that could not be read leaves the two-digit header year as ' +
|
|
224
|
+
'the only source of a date, which cannot express a year after 2084.',
|
|
225
|
+
field: 'recordingId',
|
|
226
|
+
byteOffset: 88,
|
|
227
|
+
byteLength: 80,
|
|
228
|
+
raw: header.recording.raw,
|
|
229
|
+
expected: 'Startdate startdate(dd-MMM-yyyy) investigationCode technicianCode equipmentCode',
|
|
230
|
+
actual: trimEdfField(header.recording.raw),
|
|
231
|
+
specReference: 'EDF+ additional specification 4 (local recording identification)',
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Negative when `a` is earlier than `b`. Both dates are proleptic Gregorian year/month/day. */
|
|
238
|
+
function compareDates(a: EdfCalendarDate, b: EdfCalendarDate): number {
|
|
239
|
+
if (a.year !== b.year) return a.year - b.year;
|
|
240
|
+
if (a.month !== b.month) return a.month - b.month;
|
|
241
|
+
return a.day - b.day;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function checkDates(header: EdfHeader, into: EdfDiagnostic[]): void {
|
|
245
|
+
const { startTime, patient } = header;
|
|
246
|
+
|
|
247
|
+
if (startTime.dateSource === 'none') {
|
|
248
|
+
into.push(
|
|
249
|
+
createDiagnostic({
|
|
250
|
+
code: 'DATE_UNPARSEABLE',
|
|
251
|
+
message:
|
|
252
|
+
`the startdate field is ${JSON.stringify(trimEdfField(header.raw.startDate))} and the ` +
|
|
253
|
+
'recording identification carries no readable Startdate, so the recording has no ' +
|
|
254
|
+
'calendar date at all. EDF specification, header record bytes 168-175. Next: ' +
|
|
255
|
+
'header.startTime.clock is still exact and every elapsed time in the file is ' +
|
|
256
|
+
'unaffected; only formatStartTimeNaive() has nothing to return.',
|
|
257
|
+
field: 'startDate',
|
|
258
|
+
byteOffset: 168,
|
|
259
|
+
byteLength: 8,
|
|
260
|
+
raw: header.raw.startDate,
|
|
261
|
+
expected: 'dd.mm.yy, or a Startdate subfield in the recording identification',
|
|
262
|
+
actual: trimEdfField(header.raw.startDate),
|
|
263
|
+
specReference: 'EDF specification, header record bytes 168-175',
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const headerDate = startTime.headerDate;
|
|
269
|
+
const recordingIdDate = startTime.recordingIdDate;
|
|
270
|
+
if (
|
|
271
|
+
headerDate !== undefined &&
|
|
272
|
+
recordingIdDate !== undefined &&
|
|
273
|
+
!calendarDatesEqual(headerDate, recordingIdDate)
|
|
274
|
+
) {
|
|
275
|
+
into.push(
|
|
276
|
+
createDiagnostic({
|
|
277
|
+
code: 'DATE_FIELDS_DISAGREE',
|
|
278
|
+
message:
|
|
279
|
+
`the startdate field resolves to ${formatCalendarDate(headerDate)} but the recording ` +
|
|
280
|
+
`identification Startdate says ${formatCalendarDate(recordingIdDate)}. EDF+ additional ` +
|
|
281
|
+
'specification 4: the two state the same day, and only the second can express a ' +
|
|
282
|
+
'four-digit year. Next: both are exposed on header.startTime and edfcore picks no ' +
|
|
283
|
+
`winner beyond dateSource, which is ${JSON.stringify(startTime.dateSource)} here.`,
|
|
284
|
+
field: 'startDate',
|
|
285
|
+
byteOffset: 168,
|
|
286
|
+
byteLength: 8,
|
|
287
|
+
raw: header.raw.startDate,
|
|
288
|
+
expected: formatCalendarDate(recordingIdDate),
|
|
289
|
+
actual: formatCalendarDate(headerDate),
|
|
290
|
+
specReference: 'EDF+ additional specification 4 (local recording identification)',
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const resolved = startTime.resolvedDate;
|
|
296
|
+
if (resolved !== undefined && !isValidCalendarDate(resolved)) {
|
|
297
|
+
into.push(
|
|
298
|
+
createDiagnostic({
|
|
299
|
+
code: 'DATE_IMPLAUSIBLE',
|
|
300
|
+
message:
|
|
301
|
+
`the recording start date reads ${formatCalendarDate(resolved)}, which is not a day ` +
|
|
302
|
+
'that exists. EDF specification, header record bytes 168-175. Next: the fields are ' +
|
|
303
|
+
'exposed exactly as they were read and nothing is corrected — a date edfcore repaired ' +
|
|
304
|
+
'would be indistinguishable from one the equipment got right.',
|
|
305
|
+
field: 'startDate',
|
|
306
|
+
byteOffset: 168,
|
|
307
|
+
byteLength: 8,
|
|
308
|
+
raw: header.raw.startDate,
|
|
309
|
+
expected: 'an existing calendar day',
|
|
310
|
+
actual: formatCalendarDate(resolved),
|
|
311
|
+
specReference: 'EDF specification, header record bytes 168-175',
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const birthDate = patient.birthDate;
|
|
317
|
+
if (birthDate !== undefined && resolved !== undefined && compareDates(birthDate, resolved) > 0) {
|
|
318
|
+
into.push(
|
|
319
|
+
createDiagnostic({
|
|
320
|
+
code: 'DATE_IMPLAUSIBLE',
|
|
321
|
+
message:
|
|
322
|
+
`the patient birthdate ${formatCalendarDate(birthDate)} is after the recording start ` +
|
|
323
|
+
`date ${formatCalendarDate(resolved)}. EDF+ additional specification 3. Next: both are ` +
|
|
324
|
+
'exposed as read; the usual cause is a two-digit header year resolved through the ' +
|
|
325
|
+
'1985-2084 rule for a recording made outside that window, which the recording ' +
|
|
326
|
+
'identification Startdate would settle.',
|
|
327
|
+
field: 'patientId',
|
|
328
|
+
byteOffset: 8,
|
|
329
|
+
byteLength: 80,
|
|
330
|
+
raw: header.patient.raw,
|
|
331
|
+
expected: `a birthdate at or before ${formatCalendarDate(resolved)}`,
|
|
332
|
+
actual: formatCalendarDate(birthDate),
|
|
333
|
+
specReference: 'EDF+ additional specification 3 (local patient identification)',
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Header conformance, as a standalone list.
|
|
341
|
+
*
|
|
342
|
+
* Pure, synchronous and independent of `header.diagnostics`: none of these checks affects a byte
|
|
343
|
+
* offset, so none of them is on the read path, and running them twice costs nothing.
|
|
344
|
+
*/
|
|
345
|
+
export function validateHeader(header: EdfHeader): readonly EdfDiagnostic[] {
|
|
346
|
+
const diagnostics: EdfDiagnostic[] = [];
|
|
347
|
+
|
|
348
|
+
checkRecordSize(header, diagnostics);
|
|
349
|
+
for (const signalIndex of header.dataSignalIndices) {
|
|
350
|
+
const signal = header.signals[signalIndex];
|
|
351
|
+
if (signal === undefined) continue;
|
|
352
|
+
checkLabelConvention(header, signal, diagnostics);
|
|
353
|
+
checkPrefiltering(header, signal, diagnostics);
|
|
354
|
+
checkTransducer(header, signal, diagnostics);
|
|
355
|
+
}
|
|
356
|
+
checkIdentification(header, diagnostics);
|
|
357
|
+
checkDates(header, diagnostics);
|
|
358
|
+
|
|
359
|
+
return Object.freeze(diagnostics);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Running totals for one signal, kept as plain numbers so the scan loop stays branch-light. */
|
|
363
|
+
interface StatsAccumulator {
|
|
364
|
+
readonly signalIndex: number;
|
|
365
|
+
min: number;
|
|
366
|
+
max: number;
|
|
367
|
+
outOfDigitalRangeCount: number;
|
|
368
|
+
sampleCount: number;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function finaliseStats(accumulator: StatsAccumulator): ObservedSignalStats {
|
|
372
|
+
const empty = accumulator.sampleCount === 0;
|
|
373
|
+
return {
|
|
374
|
+
signalIndex: accumulator.signalIndex,
|
|
375
|
+
// A signal with no samples has no observed extremes. Reporting 0/0 rather than
|
|
376
|
+
// Infinity/-Infinity keeps the struct numeric; `sampleCount === 0` is what says it is empty.
|
|
377
|
+
observedDigitalMin: empty ? 0 : accumulator.min,
|
|
378
|
+
observedDigitalMax: empty ? 0 : accumulator.max,
|
|
379
|
+
outOfDigitalRangeCount: accumulator.outOfDigitalRangeCount,
|
|
380
|
+
sampleCount: accumulator.sampleCount,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A supplied index is only usable when it covers this file completely. */
|
|
385
|
+
function usableIndex(
|
|
386
|
+
index: EdfRecordIndex | undefined,
|
|
387
|
+
recordCount: number,
|
|
388
|
+
): EdfRecordIndex | undefined {
|
|
389
|
+
if (index === undefined) return undefined;
|
|
390
|
+
if (index.coverage !== 'complete') return undefined;
|
|
391
|
+
if (index.recordCount !== recordCount) return undefined;
|
|
392
|
+
if (index.segments === undefined || index.gaps === undefined) return undefined;
|
|
393
|
+
return index;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function reportStructure(
|
|
397
|
+
header: EdfHeader,
|
|
398
|
+
segmentCount: number,
|
|
399
|
+
gaps: readonly EdfGap[],
|
|
400
|
+
into: EdfDiagnostic[],
|
|
401
|
+
): void {
|
|
402
|
+
if (header.continuity === 'continuous' && segmentCount > 1) {
|
|
403
|
+
into.push(
|
|
404
|
+
createDiagnostic({
|
|
405
|
+
code: 'DISCONTINUITY_IN_CONTINUOUS_FILE',
|
|
406
|
+
message:
|
|
407
|
+
`the reserved field marks this file continuous, but its record onsets fall into ` +
|
|
408
|
+
`${segmentCount} separate segments with ${gaps.length} gap(s) between them. Rule: in a ` +
|
|
409
|
+
'continuous file every record onset is startOffset + recordIndex * recordDuration; a ' +
|
|
410
|
+
'recording with gaps is what EDF+D exists for. Next: treat the file as discontinuous — ' +
|
|
411
|
+
'readWindow() returns one chunk per contiguous run once you pass it a complete index, ' +
|
|
412
|
+
'instead of crossing a gap silently.',
|
|
413
|
+
field: 'timekeeping TAL',
|
|
414
|
+
expected: '1 contiguous segment',
|
|
415
|
+
actual: `${segmentCount} segments`,
|
|
416
|
+
specReference: TIMEKEEPING_SPEC,
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
for (const gap of gaps) {
|
|
422
|
+
if (gap.durationSeconds >= 0) continue;
|
|
423
|
+
into.push(
|
|
424
|
+
createDiagnostic({
|
|
425
|
+
code: 'RECORD_ONSET_SPACING_VIOLATION',
|
|
426
|
+
message:
|
|
427
|
+
`segment ${gap.afterSegmentIndex} starts at ${gap.endSeconds} s, before segment ` +
|
|
428
|
+
`${gap.beforeSegmentIndex} ends at ${gap.startSeconds} s, so those records overlap in ` +
|
|
429
|
+
`time by ${-gap.durationSeconds} s. Rule: consecutive record onsets are spaced by at ` +
|
|
430
|
+
'least the record duration — a discontinuous file may leave gaps between records but ' +
|
|
431
|
+
'never overlaps them. Next: the onsets were used exactly as written and nothing was ' +
|
|
432
|
+
'reordered; index.segments shows which records are involved.',
|
|
433
|
+
field: 'timekeeping TAL',
|
|
434
|
+
expected: `a start at or after ${gap.startSeconds} s`,
|
|
435
|
+
actual: `${gap.endSeconds} s`,
|
|
436
|
+
specReference: TIMEKEEPING_SPEC,
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
interface Traversal {
|
|
443
|
+
readonly onsets: BigInt64Array;
|
|
444
|
+
readonly diagnostics: readonly EdfDiagnostic[];
|
|
445
|
+
readonly recordsScanned: number;
|
|
446
|
+
readonly bytesRead: number;
|
|
447
|
+
readonly signalStats: readonly ObservedSignalStats[];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* ONE pass over the file, doing every job that needs the bytes.
|
|
452
|
+
*
|
|
453
|
+
* The annotation regions and the sample blocks of a record live in the same bytes, so decoding
|
|
454
|
+
* onsets and observing sample extremes in the same pass costs one traversal rather than two. That
|
|
455
|
+
* is also why `options.index` saves anything: with a complete index the onsets are already known,
|
|
456
|
+
* so a sweep that does not want sample statistics needs no traversal at all.
|
|
457
|
+
*
|
|
458
|
+
* Chunk size comes from `record-index.ts`, so a validation sweep and an index build have the same
|
|
459
|
+
* bounded working set.
|
|
460
|
+
*/
|
|
461
|
+
async function traverse(
|
|
462
|
+
recording: EdfRecording,
|
|
463
|
+
options: ValidateOptions | undefined,
|
|
464
|
+
scanSamples: boolean,
|
|
465
|
+
): Promise<Traversal> {
|
|
466
|
+
const { source, header } = recording;
|
|
467
|
+
const recordCount = header.recordCount;
|
|
468
|
+
const onsets = new BigInt64Array(recordCount);
|
|
469
|
+
const diagnostics: EdfDiagnostic[] = [];
|
|
470
|
+
|
|
471
|
+
const accumulators: StatsAccumulator[] = scanSamples
|
|
472
|
+
? header.dataSignalIndices.map((signalIndex) => ({
|
|
473
|
+
signalIndex,
|
|
474
|
+
min: Number.POSITIVE_INFINITY,
|
|
475
|
+
max: Number.NEGATIVE_INFINITY,
|
|
476
|
+
outOfDigitalRangeCount: 0,
|
|
477
|
+
sampleCount: 0,
|
|
478
|
+
}))
|
|
479
|
+
: [];
|
|
480
|
+
|
|
481
|
+
const chunkRecords = scanChunkRecords(header, options?.maxMaterializeBytes);
|
|
482
|
+
// One scratch array for every signal and every chunk. Bounded by chunkRecords *
|
|
483
|
+
// max(samplesPerRecord) * 4 bytes, which is at most twice the chunk's own byte size.
|
|
484
|
+
let scratch: Int32Array | undefined;
|
|
485
|
+
if (scanSamples) {
|
|
486
|
+
let maxSamplesPerRecord = 0;
|
|
487
|
+
for (const accumulator of accumulators) {
|
|
488
|
+
const signal = header.signals[accumulator.signalIndex];
|
|
489
|
+
if (signal !== undefined) {
|
|
490
|
+
maxSamplesPerRecord = Math.max(maxSamplesPerRecord, signal.samplesPerRecord);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
scratch = new Int32Array(chunkRecords * maxSamplesPerRecord);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let recordsScanned = 0;
|
|
497
|
+
let bytesRead = 0;
|
|
498
|
+
while (recordsScanned < recordCount) {
|
|
499
|
+
const records: RecordRange = {
|
|
500
|
+
start: recordsScanned,
|
|
501
|
+
count: Math.min(chunkRecords, recordCount - recordsScanned),
|
|
502
|
+
};
|
|
503
|
+
const bytes = await readRecordBytes(source, header, records, options);
|
|
504
|
+
bytesRead += bytes.length;
|
|
505
|
+
|
|
506
|
+
// Never strict: a sweep whose job is to list every defect must not stop at the first one.
|
|
507
|
+
const decoded = decodeAnnotations(header, bytes, records);
|
|
508
|
+
onsets.set(decoded.recordOnsetTicks, records.start);
|
|
509
|
+
diagnostics.push(...decoded.diagnostics);
|
|
510
|
+
|
|
511
|
+
for (const accumulator of accumulators) {
|
|
512
|
+
const digital = decodeDigitalCounted(
|
|
513
|
+
header,
|
|
514
|
+
bytes,
|
|
515
|
+
records,
|
|
516
|
+
accumulator.signalIndex,
|
|
517
|
+
scratch,
|
|
518
|
+
options,
|
|
519
|
+
);
|
|
520
|
+
const samples = digital.digital;
|
|
521
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
522
|
+
// biome-ignore lint/style/noNonNullAssertion: i is bounded by samples.length.
|
|
523
|
+
const value = samples[i]!;
|
|
524
|
+
if (value < accumulator.min) accumulator.min = value;
|
|
525
|
+
if (value > accumulator.max) accumulator.max = value;
|
|
526
|
+
}
|
|
527
|
+
accumulator.outOfDigitalRangeCount += digital.outOfDigitalRangeCount;
|
|
528
|
+
accumulator.sampleCount += samples.length;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
recordsScanned += records.count;
|
|
532
|
+
options?.onProgress?.(recordsScanned, recordCount);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
onsets,
|
|
537
|
+
diagnostics,
|
|
538
|
+
recordsScanned,
|
|
539
|
+
bytesRead,
|
|
540
|
+
signalStats: accumulators.map(finaliseStats),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The full conformance sweep.
|
|
546
|
+
*
|
|
547
|
+
* What it costs is stated rather than hidden: `recordsScanned` and `bytesRead` are what actually
|
|
548
|
+
* happened, so a report claiming a file is clean also says how much of it was looked at. A sweep
|
|
549
|
+
* with `scanSamples: false` over a file whose onsets are already known — a complete
|
|
550
|
+
* `options.index`, or a plain EDF whose onsets are arithmetic — reads nothing at all and says so.
|
|
551
|
+
*
|
|
552
|
+
* `diagnostics` gathers everything known about the recording in one array: what the header parse
|
|
553
|
+
* found, what `validateHeader` re-checks, what the timeline probes saw, and what the traversal
|
|
554
|
+
* decoded. Duplicates between them are left in — deduplicating would silently drop the second of
|
|
555
|
+
* two genuinely different occurrences of the same code.
|
|
556
|
+
*
|
|
557
|
+
* A non-monotonic timeline still THROWS, here as everywhere: it is always fatal, because no
|
|
558
|
+
* answer derived from those onsets would mean anything.
|
|
559
|
+
*/
|
|
560
|
+
export async function validateRecording(
|
|
561
|
+
recording: EdfRecording,
|
|
562
|
+
options?: ValidateOptions,
|
|
563
|
+
): Promise<ValidationReport> {
|
|
564
|
+
const { header, timeline } = recording;
|
|
565
|
+
const recordCount = header.recordCount;
|
|
566
|
+
const scanSamples = options?.scanSamples === true;
|
|
567
|
+
const supplied = usableIndex(options?.index, recordCount);
|
|
568
|
+
const onsetsAreArithmetic = header.annotationSignalIndices.length === 0;
|
|
569
|
+
|
|
570
|
+
const diagnostics: EdfDiagnostic[] = [
|
|
571
|
+
...header.diagnostics,
|
|
572
|
+
...validateHeader(header),
|
|
573
|
+
...timeline.diagnostics,
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
const mustReadOnsets = supplied === undefined && !onsetsAreArithmetic;
|
|
577
|
+
const traversal =
|
|
578
|
+
scanSamples || mustReadOnsets ? await traverse(recording, options, scanSamples) : undefined;
|
|
579
|
+
if (traversal !== undefined) diagnostics.push(...traversal.diagnostics);
|
|
580
|
+
|
|
581
|
+
let segmentCount: number;
|
|
582
|
+
let gaps: readonly EdfGap[];
|
|
583
|
+
if (supplied?.segments !== undefined && supplied.gaps !== undefined) {
|
|
584
|
+
segmentCount = supplied.segments.length;
|
|
585
|
+
gaps = supplied.gaps;
|
|
586
|
+
} else if (traversal !== undefined) {
|
|
587
|
+
assertMonotonicOnsetArray(traversal.onsets);
|
|
588
|
+
const segmentation = buildSegmentation(
|
|
589
|
+
traversal.onsets,
|
|
590
|
+
header.recordDurationTicks,
|
|
591
|
+
timeline.startOffsetTicks,
|
|
592
|
+
);
|
|
593
|
+
segmentCount = segmentation.segments.length;
|
|
594
|
+
gaps = segmentation.gaps;
|
|
595
|
+
} else {
|
|
596
|
+
// No stored onsets and none read: record r starts at r * recordDuration by definition, so the
|
|
597
|
+
// recording is one segment and there is nothing structural left to disagree about.
|
|
598
|
+
segmentCount = recordCount > 0 ? 1 : 0;
|
|
599
|
+
gaps = [];
|
|
600
|
+
}
|
|
601
|
+
reportStructure(header, segmentCount, gaps, diagnostics);
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
ok: diagnostics.every((diagnostic) => diagnostic.severity !== 'error'),
|
|
605
|
+
diagnostics: Object.freeze(diagnostics),
|
|
606
|
+
recordsScanned: traversal?.recordsScanned ?? 0,
|
|
607
|
+
bytesRead: traversal?.bytesRead ?? 0,
|
|
608
|
+
signalStats: Object.freeze(traversal?.signalStats ?? []),
|
|
609
|
+
};
|
|
610
|
+
}
|