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,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every date rule in EDF, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Layer 2. Sole owner of: the `dd.mm.yy` header startdate, the `hh.mm.ss` clock, the
|
|
5
|
+
* `dd-MMM-yyyy` subfield date used by the patient birthdate and the recording-identification
|
|
6
|
+
* `Startdate`, the 1985-2084 two-digit-year rule, the literal `'yy'` post-2084 escape, and the
|
|
7
|
+
* resolution between the two dates a file can carry.
|
|
8
|
+
*
|
|
9
|
+
* A JavaScript `Date` is never constructed. EDF stores local time at the patient with no
|
|
10
|
+
* timezone, so a `Date` would silently apply the reader's zone — worst exactly at DST
|
|
11
|
+
* boundaries — and `lib: ["ES2022"]` gives us no reason to reach for one anyway. Month lengths
|
|
12
|
+
* and leap years are arithmetic, and `'31.02.99'` must never quietly become 3 March.
|
|
13
|
+
*
|
|
14
|
+
* Separators and stray spaces are tolerated (`' 2. 8.51'` is in the EDF FAQ). A tolerated
|
|
15
|
+
* deviation is reported through `conformant` on the parse result rather than as a diagnostic:
|
|
16
|
+
* the vocabulary in `diagnostics/codes.ts` has no code for a date that parsed but was written
|
|
17
|
+
* oddly, and inventing one is not this module's call.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { trimEdfField } from '../bytes/latin1.js';
|
|
21
|
+
import { HEADER_FIELDS } from '../constants.js';
|
|
22
|
+
import type { DiagnosticSink } from '../diagnostics/collector.js';
|
|
23
|
+
import type { EdfCalendarDate, EdfClockTime, EdfStartTime } from '../types.js';
|
|
24
|
+
|
|
25
|
+
const MONTH_ABBREVIATIONS: readonly string[] = [
|
|
26
|
+
'JAN',
|
|
27
|
+
'FEB',
|
|
28
|
+
'MAR',
|
|
29
|
+
'APR',
|
|
30
|
+
'MAY',
|
|
31
|
+
'JUN',
|
|
32
|
+
'JUL',
|
|
33
|
+
'AUG',
|
|
34
|
+
'SEP',
|
|
35
|
+
'OCT',
|
|
36
|
+
'NOV',
|
|
37
|
+
'DEC',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const DAYS_IN_MONTH: readonly number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
41
|
+
|
|
42
|
+
const MONTHS_PER_YEAR = 12;
|
|
43
|
+
const FEBRUARY = 2;
|
|
44
|
+
const LEAP_FEBRUARY_DAYS = 29;
|
|
45
|
+
|
|
46
|
+
const MAX_HOUR = 23;
|
|
47
|
+
const MAX_MINUTE = 59;
|
|
48
|
+
/** No leap seconds: EDF has no field that could express one, and none is expected. */
|
|
49
|
+
const MAX_SECOND = 59;
|
|
50
|
+
|
|
51
|
+
const SECONDS_PER_MINUTE = 60;
|
|
52
|
+
const SECONDS_PER_HOUR = 3600;
|
|
53
|
+
|
|
54
|
+
/** 85..99 mean 1985..1999; 00..84 mean 2000..2084. The escape past 2084 is a literal `'yy'`. */
|
|
55
|
+
const TWO_DIGIT_YEAR_PIVOT = 85;
|
|
56
|
+
const TWENTIETH_CENTURY = 1900;
|
|
57
|
+
const TWENTY_FIRST_CENTURY = 2000;
|
|
58
|
+
|
|
59
|
+
const DIGIT_ZERO = 0x30;
|
|
60
|
+
const DIGIT_NINE = 0x39;
|
|
61
|
+
|
|
62
|
+
/** `'.'`, `':'`, `'-'`, `'/'` and stray spaces all occur in the wild. */
|
|
63
|
+
const DATE_FIELD_SEPARATORS = /[\s.:/-]+/;
|
|
64
|
+
/** Exactly `dd.mm.yy`, including the post-2084 escape in the year position. */
|
|
65
|
+
const CONFORMANT_HEADER_DATE = /^\d{2}\.\d{2}\.(?:\d{2}|yy)$/;
|
|
66
|
+
/** Exactly `hh.mm.ss`. */
|
|
67
|
+
const CONFORMANT_HEADER_TIME = /^\d{2}\.\d{2}\.\d{2}$/;
|
|
68
|
+
/** Exactly `dd-MMM-yyyy`: zero-padded day, uppercase English month, four-digit year. */
|
|
69
|
+
const CONFORMANT_SUBFIELD_DATE = /^\d{2}-[A-Z]{3}-\d{4}$/;
|
|
70
|
+
const YEAR_ESCAPE = /^yy$/i;
|
|
71
|
+
|
|
72
|
+
const MIDNIGHT: EdfClockTime = { hour: 0, minute: 0, second: 0 };
|
|
73
|
+
|
|
74
|
+
export function isLeapYear(year: number): boolean {
|
|
75
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function daysInMonth(year: number, month: number): number {
|
|
79
|
+
if (month === FEBRUARY && isLeapYear(year)) return LEAP_FEBRUARY_DAYS;
|
|
80
|
+
return DAYS_IN_MONTH[month - 1] ?? 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The longest that month can ever be, for validating a day whose year is not yet known. */
|
|
84
|
+
function maxDaysInMonth(month: number): number {
|
|
85
|
+
if (month === FEBRUARY) return LEAP_FEBRUARY_DAYS;
|
|
86
|
+
return DAYS_IN_MONTH[month - 1] ?? 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isValidCalendarDate(date: EdfCalendarDate): boolean {
|
|
90
|
+
if (!Number.isInteger(date.year) || date.year < 1 || date.year > 9999) return false;
|
|
91
|
+
if (!Number.isInteger(date.month) || date.month < 1 || date.month > MONTHS_PER_YEAR) return false;
|
|
92
|
+
if (!Number.isInteger(date.day) || date.day < 1) return false;
|
|
93
|
+
return date.day <= daysInMonth(date.year, date.month);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function calendarDatesEqual(a: EdfCalendarDate, b: EdfCalendarDate): boolean {
|
|
97
|
+
return a.year === b.year && a.month === b.month && a.day === b.day;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `'1951-08-02'`. Used in diagnostic messages and by `formatStartTimeNaive`. */
|
|
101
|
+
export function formatCalendarDate(date: EdfCalendarDate): string {
|
|
102
|
+
return `${pad(date.year, 4)}-${pad(date.month, 2)}-${pad(date.day, 2)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `'09:00:00'`. */
|
|
106
|
+
export function formatClockTime(clock: EdfClockTime): string {
|
|
107
|
+
return `${pad(clock.hour, 2)}:${pad(clock.minute, 2)}:${pad(clock.second, 2)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pad(value: number, width: number): string {
|
|
111
|
+
return String(value).padStart(width, '0');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** All digits, `maxLength` of them at most, no sign and no separators. */
|
|
115
|
+
function parseDigits(text: string, maxLength: number): number | undefined {
|
|
116
|
+
if (text.length === 0 || text.length > maxLength) return undefined;
|
|
117
|
+
for (let i = 0; i < text.length; i++) {
|
|
118
|
+
const code = text.charCodeAt(i);
|
|
119
|
+
if (code < DIGIT_ZERO || code > DIGIT_NINE) return undefined;
|
|
120
|
+
}
|
|
121
|
+
return Number(text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Split a date or time field into exactly three parts, tolerating any of the separators real
|
|
126
|
+
* writers emit and any amount of stray space. Returns `undefined` when there are not three.
|
|
127
|
+
*/
|
|
128
|
+
function threeFields(text: string): readonly [string, string, string] | undefined {
|
|
129
|
+
const parts = trimEdfField(text)
|
|
130
|
+
.split(DATE_FIELD_SEPARATORS)
|
|
131
|
+
.filter((part) => part.length > 0);
|
|
132
|
+
if (parts.length !== 3) return undefined;
|
|
133
|
+
const first = parts[0];
|
|
134
|
+
const second = parts[1];
|
|
135
|
+
const third = parts[2];
|
|
136
|
+
if (first === undefined || second === undefined || third === undefined) return undefined;
|
|
137
|
+
return [first, second, third];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resolveTwoDigitYear(twoDigitYear: number): number {
|
|
141
|
+
return twoDigitYear >= TWO_DIGIT_YEAR_PIVOT
|
|
142
|
+
? TWENTIETH_CENTURY + twoDigitYear
|
|
143
|
+
: TWENTY_FIRST_CENTURY + twoDigitYear;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `parsed` — a full date. `yearEscape` — the year position held the literal `'yy'`, so only the
|
|
148
|
+
* recording-identification `Startdate` knows the year. `unparseable` — nothing usable.
|
|
149
|
+
*/
|
|
150
|
+
export type HeaderStartDateStatus = 'parsed' | 'yearEscape' | 'unparseable';
|
|
151
|
+
|
|
152
|
+
export interface HeaderStartDateParse {
|
|
153
|
+
readonly status: HeaderStartDateStatus;
|
|
154
|
+
/** Present only when `status === 'parsed'`. */
|
|
155
|
+
readonly date: EdfCalendarDate | undefined;
|
|
156
|
+
/** Present when the day and month parsed, including under the year escape. */
|
|
157
|
+
readonly day: number | undefined;
|
|
158
|
+
readonly month: number | undefined;
|
|
159
|
+
/** The 1985-2084 rule was applied to a two-digit year. */
|
|
160
|
+
readonly clippedYear: boolean;
|
|
161
|
+
/** The field is exactly `dd.mm.yy`. False means it parsed only because we tolerate. */
|
|
162
|
+
readonly conformant: boolean;
|
|
163
|
+
readonly raw: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function unparseableDate(raw: string, conformant: boolean): HeaderStartDateParse {
|
|
167
|
+
return {
|
|
168
|
+
status: 'unparseable',
|
|
169
|
+
date: undefined,
|
|
170
|
+
day: undefined,
|
|
171
|
+
month: undefined,
|
|
172
|
+
clippedYear: false,
|
|
173
|
+
conformant,
|
|
174
|
+
raw,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The `dd.mm.yy` startdate at offset 168.
|
|
180
|
+
*
|
|
181
|
+
* A four-digit year is accepted where it fits (`'2.8.1951'`), because a writer that spells the
|
|
182
|
+
* year out is unambiguous and the two-digit rule would be a downgrade. Three digits are
|
|
183
|
+
* corruption, not a convention, and are refused.
|
|
184
|
+
*/
|
|
185
|
+
export function parseHeaderStartDate(raw: string): HeaderStartDateParse {
|
|
186
|
+
const conformant = CONFORMANT_HEADER_DATE.test(raw);
|
|
187
|
+
const fields = threeFields(raw);
|
|
188
|
+
if (fields === undefined) return unparseableDate(raw, conformant);
|
|
189
|
+
|
|
190
|
+
const [dayText, monthText, yearText] = fields;
|
|
191
|
+
const day = parseDigits(dayText, 2);
|
|
192
|
+
const month = parseDigits(monthText, 2);
|
|
193
|
+
if (day === undefined || month === undefined) return unparseableDate(raw, conformant);
|
|
194
|
+
if (month < 1 || month > MONTHS_PER_YEAR) return unparseableDate(raw, conformant);
|
|
195
|
+
|
|
196
|
+
if (YEAR_ESCAPE.test(yearText)) {
|
|
197
|
+
// The year is genuinely unknown here, so the day can only be checked against the longest
|
|
198
|
+
// that month ever gets; the real year re-checks it in `resolveStartTime`.
|
|
199
|
+
if (day < 1 || day > maxDaysInMonth(month)) return unparseableDate(raw, conformant);
|
|
200
|
+
return {
|
|
201
|
+
status: 'yearEscape',
|
|
202
|
+
date: undefined,
|
|
203
|
+
day,
|
|
204
|
+
month,
|
|
205
|
+
clippedYear: false,
|
|
206
|
+
conformant,
|
|
207
|
+
raw,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const yearDigits = parseDigits(yearText, 4);
|
|
212
|
+
if (yearDigits === undefined) return unparseableDate(raw, conformant);
|
|
213
|
+
if (yearText.length === 3) return unparseableDate(raw, conformant);
|
|
214
|
+
const clippedYear = yearText.length <= 2;
|
|
215
|
+
const year = clippedYear ? resolveTwoDigitYear(yearDigits) : yearDigits;
|
|
216
|
+
|
|
217
|
+
const date: EdfCalendarDate = { year, month, day };
|
|
218
|
+
if (!isValidCalendarDate(date)) return unparseableDate(raw, conformant);
|
|
219
|
+
return { status: 'parsed', date, day, month, clippedYear, conformant, raw };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface HeaderStartTimeParse {
|
|
223
|
+
readonly clock: EdfClockTime | undefined;
|
|
224
|
+
/** The field is exactly `hh.mm.ss`. */
|
|
225
|
+
readonly conformant: boolean;
|
|
226
|
+
readonly raw: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The `hh.mm.ss` starttime at offset 176. Whole seconds only — EDF has no finer field. */
|
|
230
|
+
export function parseHeaderStartTime(raw: string): HeaderStartTimeParse {
|
|
231
|
+
const conformant = CONFORMANT_HEADER_TIME.test(raw);
|
|
232
|
+
const fields = threeFields(raw);
|
|
233
|
+
if (fields === undefined) return { clock: undefined, conformant, raw };
|
|
234
|
+
|
|
235
|
+
const [hourText, minuteText, secondText] = fields;
|
|
236
|
+
const hour = parseDigits(hourText, 2);
|
|
237
|
+
const minute = parseDigits(minuteText, 2);
|
|
238
|
+
const second = parseDigits(secondText, 2);
|
|
239
|
+
if (hour === undefined || minute === undefined || second === undefined) {
|
|
240
|
+
return { clock: undefined, conformant, raw };
|
|
241
|
+
}
|
|
242
|
+
if (hour > MAX_HOUR || minute > MAX_MINUTE || second > MAX_SECOND) {
|
|
243
|
+
return { clock: undefined, conformant, raw };
|
|
244
|
+
}
|
|
245
|
+
return { clock: { hour, minute, second }, conformant, raw };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface SubfieldDateParse {
|
|
249
|
+
readonly date: EdfCalendarDate | undefined;
|
|
250
|
+
/** The text is exactly `dd-MMM-yyyy` with an uppercase English month, and names a real day. */
|
|
251
|
+
readonly conformant: boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The `dd-MMM-yyyy` subfield date — `'02-AUG-1951'` — used by the patient birthdate and the
|
|
256
|
+
* recording-identification `Startdate`.
|
|
257
|
+
*
|
|
258
|
+
* The month is accepted case-insensitively and a two-digit numeric month is accepted too, both
|
|
259
|
+
* marked non-conformant. The year must be four digits: this field exists precisely to be
|
|
260
|
+
* unambiguous, so applying the two-digit rule to it would throw away the one thing it is for.
|
|
261
|
+
*/
|
|
262
|
+
export function parseSubfieldDate(text: string): SubfieldDateParse {
|
|
263
|
+
const fields = threeFields(text);
|
|
264
|
+
if (fields === undefined) return { date: undefined, conformant: false };
|
|
265
|
+
|
|
266
|
+
const [dayText, monthText, yearText] = fields;
|
|
267
|
+
const day = parseDigits(dayText, 2);
|
|
268
|
+
const year = parseDigits(yearText, 4);
|
|
269
|
+
if (day === undefined || year === undefined || yearText.length !== 4) {
|
|
270
|
+
return { date: undefined, conformant: false };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const namedMonth = MONTH_ABBREVIATIONS.indexOf(monthText.toUpperCase()) + 1;
|
|
274
|
+
const month = namedMonth > 0 ? namedMonth : parseDigits(monthText, 2);
|
|
275
|
+
if (month === undefined || month < 1 || month > MONTHS_PER_YEAR) {
|
|
276
|
+
return { date: undefined, conformant: false };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const date: EdfCalendarDate = { year, month, day };
|
|
280
|
+
if (!isValidCalendarDate(date)) return { date: undefined, conformant: false };
|
|
281
|
+
return { date, conformant: CONFORMANT_SUBFIELD_DATE.test(text) && namedMonth > 0 };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface StartTimeInput {
|
|
285
|
+
/** The raw eight bytes of the startdate field, as text, padding included. */
|
|
286
|
+
readonly rawStartDate: string;
|
|
287
|
+
/** The raw eight bytes of the starttime field, as text. */
|
|
288
|
+
readonly rawStartTime: string;
|
|
289
|
+
/** From `parseRecordingId`. The only unambiguous four-digit year a file can carry. */
|
|
290
|
+
readonly recordingIdDate: EdfCalendarDate | undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Build the whole `EdfStartTime`, including the cross-field resolution.
|
|
295
|
+
*
|
|
296
|
+
* The recording-identification date wins when both exist, because its year is unambiguous —
|
|
297
|
+
* but a disagreement is always reported and both dates stay on the result, so no winner is
|
|
298
|
+
* picked silently. `dateSource` says which one `resolvedDate` came from.
|
|
299
|
+
*/
|
|
300
|
+
export function resolveStartTime(input: StartTimeInput, sink: DiagnosticSink): EdfStartTime {
|
|
301
|
+
const dateParse = parseHeaderStartDate(input.rawStartDate);
|
|
302
|
+
const timeParse = parseHeaderStartTime(input.rawStartTime);
|
|
303
|
+
const recordingIdDate = input.recordingIdDate;
|
|
304
|
+
const dateOffset = HEADER_FIELDS.startDate.offset;
|
|
305
|
+
|
|
306
|
+
if (dateParse.status === 'unparseable') {
|
|
307
|
+
sink.report({
|
|
308
|
+
code: 'DATE_UNPARSEABLE',
|
|
309
|
+
message:
|
|
310
|
+
`startdate field (8 bytes at offset ${dateOffset}) is ` +
|
|
311
|
+
`${JSON.stringify(dateParse.raw)}, which is not a real date in dd.mm.yy form: the ` +
|
|
312
|
+
'day must exist in that month of that year, and the month must be 01..12. EDF ' +
|
|
313
|
+
'specification, header record bytes 168-175. Next: header.raw.startDate keeps the ' +
|
|
314
|
+
'text verbatim, and startTime.recordingIdDate is used instead when the EDF+ ' +
|
|
315
|
+
'recording identification carries a Startdate.',
|
|
316
|
+
field: 'startDate',
|
|
317
|
+
byteOffset: dateOffset,
|
|
318
|
+
byteLength: HEADER_FIELDS.startDate.length,
|
|
319
|
+
raw: dateParse.raw,
|
|
320
|
+
expected: 'dd.mm.yy',
|
|
321
|
+
actual: trimEdfField(dateParse.raw),
|
|
322
|
+
specReference: 'EDF specification, header record bytes 168-175',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (dateParse.clippedYear && dateParse.date !== undefined) {
|
|
327
|
+
sink.report({
|
|
328
|
+
code: 'DATE_CLIPPED_TO_1985_2084',
|
|
329
|
+
message:
|
|
330
|
+
`startdate field (8 bytes at offset ${dateOffset}) is ` +
|
|
331
|
+
`${JSON.stringify(dateParse.raw)}: its two-digit year was resolved to ` +
|
|
332
|
+
`${dateParse.date.year} by the EDF+ rule that 85..99 mean 1985..1999 and 00..84 mean ` +
|
|
333
|
+
'2000..2084, so the field cannot express a year outside that span. EDF+ additional ' +
|
|
334
|
+
'specification 2 (1985 is the clipping date). Next: for an unambiguous year read ' +
|
|
335
|
+
'startTime.recordingIdDate, which the EDF+ recording identification spells out in ' +
|
|
336
|
+
'four digits.',
|
|
337
|
+
field: 'startDate',
|
|
338
|
+
byteOffset: dateOffset,
|
|
339
|
+
byteLength: HEADER_FIELDS.startDate.length,
|
|
340
|
+
raw: dateParse.raw,
|
|
341
|
+
expected: '1985..2084',
|
|
342
|
+
actual: String(dateParse.date.year),
|
|
343
|
+
specReference: 'EDF+ additional specification 2 (startdate and starttime)',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (dateParse.status === 'yearEscape' && recordingIdDate === undefined) {
|
|
348
|
+
sink.report({
|
|
349
|
+
code: 'DATE_UNPARSEABLE',
|
|
350
|
+
message:
|
|
351
|
+
`startdate field (8 bytes at offset ${dateOffset}) is ` +
|
|
352
|
+
`${JSON.stringify(dateParse.raw)}: the literal "yy" is the EDF+ escape for a ` +
|
|
353
|
+
'recording after 2084, and it requires the year to be given by the Startdate ' +
|
|
354
|
+
'subfield of the recording identification, which this file does not carry. EDF+ ' +
|
|
355
|
+
'additional specifications 2 and 4. Next: the recording has no resolvable date; ' +
|
|
356
|
+
'startTime.clock is still valid.',
|
|
357
|
+
field: 'startDate',
|
|
358
|
+
byteOffset: dateOffset,
|
|
359
|
+
byteLength: HEADER_FIELDS.startDate.length,
|
|
360
|
+
raw: dateParse.raw,
|
|
361
|
+
expected: 'a Startdate subfield in the local recording identification',
|
|
362
|
+
actual: trimEdfField(dateParse.raw),
|
|
363
|
+
specReference: 'EDF+ additional specifications 2 and 4',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const headerDate = dateParse.date;
|
|
368
|
+
const disagrees =
|
|
369
|
+
recordingIdDate !== undefined &&
|
|
370
|
+
((headerDate !== undefined && !calendarDatesEqual(headerDate, recordingIdDate)) ||
|
|
371
|
+
// Under the escape the header still states a day and a month, and they must match.
|
|
372
|
+
(dateParse.status === 'yearEscape' &&
|
|
373
|
+
(dateParse.day !== recordingIdDate.day || dateParse.month !== recordingIdDate.month)));
|
|
374
|
+
|
|
375
|
+
if (disagrees && recordingIdDate !== undefined) {
|
|
376
|
+
const headerText =
|
|
377
|
+
headerDate === undefined
|
|
378
|
+
? `day ${String(dateParse.day)}, month ${String(dateParse.month)}`
|
|
379
|
+
: formatCalendarDate(headerDate);
|
|
380
|
+
sink.report({
|
|
381
|
+
code: 'DATE_FIELDS_DISAGREE',
|
|
382
|
+
message:
|
|
383
|
+
`startdate field (8 bytes at offset ${dateOffset}) is ` +
|
|
384
|
+
`${JSON.stringify(dateParse.raw)}, i.e. ${headerText}, but the Startdate subfield of ` +
|
|
385
|
+
`the local recording identification says ${formatCalendarDate(recordingIdDate)}. ` +
|
|
386
|
+
'EDF+ additional specifications 2 and 4 both define the start of the recording, so ' +
|
|
387
|
+
'the two must name the same day; when only the year differs the usual cause is that ' +
|
|
388
|
+
'the dd.mm.yy field cannot express a year outside 1985..2084. Next: both are ' +
|
|
389
|
+
'exposed as ' +
|
|
390
|
+
'startTime.headerDate and startTime.recordingIdDate and edfcore picks no silent ' +
|
|
391
|
+
'winner; resolvedDate is the four-digit recording-identification date, and ' +
|
|
392
|
+
'dateSource says so.',
|
|
393
|
+
field: 'startDate',
|
|
394
|
+
byteOffset: dateOffset,
|
|
395
|
+
byteLength: HEADER_FIELDS.startDate.length,
|
|
396
|
+
raw: dateParse.raw,
|
|
397
|
+
expected: formatCalendarDate(recordingIdDate),
|
|
398
|
+
actual: headerText,
|
|
399
|
+
specReference: 'EDF+ additional specifications 2 and 4',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (timeParse.clock === undefined) {
|
|
404
|
+
sink.report({
|
|
405
|
+
code: 'DATE_UNPARSEABLE',
|
|
406
|
+
message:
|
|
407
|
+
`starttime field (8 bytes at offset ${HEADER_FIELDS.startTime.offset}) is ` +
|
|
408
|
+
`${JSON.stringify(timeParse.raw)}, which is not a clock time in hh.mm.ss form with ` +
|
|
409
|
+
'hour 00..23, minute 00..59 and second 00..59. EDF specification, header record ' +
|
|
410
|
+
'bytes 176-183. Next: startTime.clock reports 00:00:00 because the type admits no ' +
|
|
411
|
+
'absent clock; header.raw.startTime keeps the text verbatim, and every sample offset ' +
|
|
412
|
+
'is relative to the recording start regardless.',
|
|
413
|
+
field: 'startTime',
|
|
414
|
+
byteOffset: HEADER_FIELDS.startTime.offset,
|
|
415
|
+
byteLength: HEADER_FIELDS.startTime.length,
|
|
416
|
+
raw: timeParse.raw,
|
|
417
|
+
expected: 'hh.mm.ss',
|
|
418
|
+
actual: trimEdfField(timeParse.raw),
|
|
419
|
+
specReference: 'EDF specification, header record bytes 176-183',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const clock = timeParse.clock ?? MIDNIGHT;
|
|
424
|
+
const resolvedDate = recordingIdDate ?? headerDate;
|
|
425
|
+
const dateSource: EdfStartTime['dateSource'] =
|
|
426
|
+
recordingIdDate !== undefined
|
|
427
|
+
? 'recordingIdField'
|
|
428
|
+
: headerDate !== undefined
|
|
429
|
+
? 'headerField'
|
|
430
|
+
: 'none';
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
headerDate,
|
|
434
|
+
recordingIdDate,
|
|
435
|
+
resolvedDate,
|
|
436
|
+
dateSource,
|
|
437
|
+
clock,
|
|
438
|
+
secondsSinceMidnight:
|
|
439
|
+
clock.hour * SECONDS_PER_HOUR + clock.minute * SECONDS_PER_MINUTE + clock.second,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* `'1951-08-02T09:00:00.000'` — no zone designator, because EDF has no zone.
|
|
445
|
+
*
|
|
446
|
+
* The milliseconds are always `.000`: the header stores whole seconds, and the sub-second start
|
|
447
|
+
* of an EDF+ recording lives in record 0's timekeeping TAL, not here.
|
|
448
|
+
*/
|
|
449
|
+
export function formatStartTimeNaive(startTime: EdfStartTime): string | undefined {
|
|
450
|
+
const date = startTime.resolvedDate;
|
|
451
|
+
if (date === undefined) return undefined;
|
|
452
|
+
return `${formatCalendarDate(date)}T${formatClockTime(startTime.clock)}.000`;
|
|
453
|
+
}
|