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.
Files changed (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +319 -0
  3. package/dist/bytes/latin1.d.ts +43 -0
  4. package/dist/bytes/latin1.d.ts.map +1 -0
  5. package/dist/bytes/latin1.js +84 -0
  6. package/dist/bytes/latin1.js.map +1 -0
  7. package/dist/bytes/numbers.d.ts +45 -0
  8. package/dist/bytes/numbers.d.ts.map +1 -0
  9. package/dist/bytes/numbers.js +87 -0
  10. package/dist/bytes/numbers.js.map +1 -0
  11. package/dist/bytes/view.d.ts +27 -0
  12. package/dist/bytes/view.d.ts.map +1 -0
  13. package/dist/bytes/view.js +53 -0
  14. package/dist/bytes/view.js.map +1 -0
  15. package/dist/constants.d.ts +113 -0
  16. package/dist/constants.d.ts.map +1 -0
  17. package/dist/constants.js +83 -0
  18. package/dist/constants.js.map +1 -0
  19. package/dist/decode/digital.d.ts +49 -0
  20. package/dist/decode/digital.d.ts.map +1 -0
  21. package/dist/decode/digital.js +180 -0
  22. package/dist/decode/digital.js.map +1 -0
  23. package/dist/decode/physical.d.ts +37 -0
  24. package/dist/decode/physical.d.ts.map +1 -0
  25. package/dist/decode/physical.js +178 -0
  26. package/dist/decode/physical.js.map +1 -0
  27. package/dist/diagnostics/codes.d.ts +38 -0
  28. package/dist/diagnostics/codes.d.ts.map +1 -0
  29. package/dist/diagnostics/codes.js +134 -0
  30. package/dist/diagnostics/codes.js.map +1 -0
  31. package/dist/diagnostics/collector.d.ts +76 -0
  32. package/dist/diagnostics/collector.d.ts.map +1 -0
  33. package/dist/diagnostics/collector.js +95 -0
  34. package/dist/diagnostics/collector.js.map +1 -0
  35. package/dist/diagnostics/format.d.ts +21 -0
  36. package/dist/diagnostics/format.d.ts.map +1 -0
  37. package/dist/diagnostics/format.js +125 -0
  38. package/dist/diagnostics/format.js.map +1 -0
  39. package/dist/errors.d.ts +143 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +135 -0
  42. package/dist/errors.js.map +1 -0
  43. package/dist/header/dates.d.ts +101 -0
  44. package/dist/header/dates.d.ts.map +1 -0
  45. package/dist/header/dates.js +378 -0
  46. package/dist/header/dates.js.map +1 -0
  47. package/dist/header/fields.d.ts +105 -0
  48. package/dist/header/fields.d.ts.map +1 -0
  49. package/dist/header/fields.js +363 -0
  50. package/dist/header/fields.js.map +1 -0
  51. package/dist/header/identification.d.ts +47 -0
  52. package/dist/header/identification.d.ts.map +1 -0
  53. package/dist/header/identification.js +156 -0
  54. package/dist/header/identification.js.map +1 -0
  55. package/dist/header/lookup.d.ts +33 -0
  56. package/dist/header/lookup.d.ts.map +1 -0
  57. package/dist/header/lookup.js +67 -0
  58. package/dist/header/lookup.js.map +1 -0
  59. package/dist/header/parse.d.ts +32 -0
  60. package/dist/header/parse.d.ts.map +1 -0
  61. package/dist/header/parse.js +319 -0
  62. package/dist/header/parse.js.map +1 -0
  63. package/dist/header/scale.d.ts +68 -0
  64. package/dist/header/scale.d.ts.map +1 -0
  65. package/dist/header/scale.js +154 -0
  66. package/dist/header/scale.js.map +1 -0
  67. package/dist/header/signals.d.ts +64 -0
  68. package/dist/header/signals.d.ts.map +1 -0
  69. package/dist/header/signals.js +374 -0
  70. package/dist/header/signals.js.map +1 -0
  71. package/dist/header/variant.d.ts +67 -0
  72. package/dist/header/variant.d.ts.map +1 -0
  73. package/dist/header/variant.js +180 -0
  74. package/dist/header/variant.js.map +1 -0
  75. package/dist/index.d.ts +46 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +59 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/inspect.d.ts +31 -0
  80. package/dist/inspect.d.ts.map +1 -0
  81. package/dist/inspect.js +165 -0
  82. package/dist/inspect.js.map +1 -0
  83. package/dist/io/blob.d.ts +14 -0
  84. package/dist/io/blob.d.ts.map +1 -0
  85. package/dist/io/blob.js +29 -0
  86. package/dist/io/blob.js.map +1 -0
  87. package/dist/io/bytes.d.ts +11 -0
  88. package/dist/io/bytes.d.ts.map +1 -0
  89. package/dist/io/bytes.js +26 -0
  90. package/dist/io/bytes.js.map +1 -0
  91. package/dist/io/cached.d.ts +19 -0
  92. package/dist/io/cached.d.ts.map +1 -0
  93. package/dist/io/cached.js +129 -0
  94. package/dist/io/cached.js.map +1 -0
  95. package/dist/io/http.d.ts +20 -0
  96. package/dist/io/http.d.ts.map +1 -0
  97. package/dist/io/http.js +225 -0
  98. package/dist/io/http.js.map +1 -0
  99. package/dist/io/read.d.ts +42 -0
  100. package/dist/io/read.d.ts.map +1 -0
  101. package/dist/io/read.js +119 -0
  102. package/dist/io/read.js.map +1 -0
  103. package/dist/io/source.d.ts +33 -0
  104. package/dist/io/source.d.ts.map +1 -0
  105. package/dist/io/source.js +72 -0
  106. package/dist/io/source.js.map +1 -0
  107. package/dist/node.d.ts +70 -0
  108. package/dist/node.d.ts.map +1 -0
  109. package/dist/node.js +109 -0
  110. package/dist/node.js.map +1 -0
  111. package/dist/record-index.d.ts +58 -0
  112. package/dist/record-index.d.ts.map +1 -0
  113. package/dist/record-index.js +290 -0
  114. package/dist/record-index.js.map +1 -0
  115. package/dist/recording.d.ts +57 -0
  116. package/dist/recording.d.ts.map +1 -0
  117. package/dist/recording.js +178 -0
  118. package/dist/recording.js.map +1 -0
  119. package/dist/tal/annotations.d.ts +36 -0
  120. package/dist/tal/annotations.d.ts.map +1 -0
  121. package/dist/tal/annotations.js +437 -0
  122. package/dist/tal/annotations.js.map +1 -0
  123. package/dist/tal/grammar.d.ts +111 -0
  124. package/dist/tal/grammar.d.ts.map +1 -0
  125. package/dist/tal/grammar.js +324 -0
  126. package/dist/tal/grammar.js.map +1 -0
  127. package/dist/tal/ticks.d.ts +69 -0
  128. package/dist/tal/ticks.d.ts.map +1 -0
  129. package/dist/tal/ticks.js +137 -0
  130. package/dist/tal/ticks.js.map +1 -0
  131. package/dist/time/segments.d.ts +33 -0
  132. package/dist/time/segments.d.ts.map +1 -0
  133. package/dist/time/segments.js +87 -0
  134. package/dist/time/segments.js.map +1 -0
  135. package/dist/time/timeline.d.ts +62 -0
  136. package/dist/time/timeline.d.ts.map +1 -0
  137. package/dist/time/timeline.js +227 -0
  138. package/dist/time/timeline.js.map +1 -0
  139. package/dist/time/window.d.ts +54 -0
  140. package/dist/time/window.d.ts.map +1 -0
  141. package/dist/time/window.js +232 -0
  142. package/dist/time/window.js.map +1 -0
  143. package/dist/types.d.ts +409 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +16 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/validate.d.ts +53 -0
  148. package/dist/validate.d.ts.map +1 -0
  149. package/dist/validate.js +483 -0
  150. package/dist/validate.js.map +1 -0
  151. package/package.json +77 -0
  152. package/src/bytes/latin1.ts +87 -0
  153. package/src/bytes/numbers.ts +115 -0
  154. package/src/bytes/view.ts +59 -0
  155. package/src/constants.ts +96 -0
  156. package/src/decode/digital.ts +258 -0
  157. package/src/decode/physical.ts +235 -0
  158. package/src/diagnostics/codes.ts +209 -0
  159. package/src/diagnostics/collector.ts +129 -0
  160. package/src/diagnostics/format.ts +143 -0
  161. package/src/errors.ts +208 -0
  162. package/src/header/dates.ts +453 -0
  163. package/src/header/fields.ts +455 -0
  164. package/src/header/identification.ts +196 -0
  165. package/src/header/lookup.ts +81 -0
  166. package/src/header/parse.ts +398 -0
  167. package/src/header/scale.ts +268 -0
  168. package/src/header/signals.ts +550 -0
  169. package/src/header/variant.ts +239 -0
  170. package/src/index.ts +151 -0
  171. package/src/inspect.ts +181 -0
  172. package/src/io/blob.ts +31 -0
  173. package/src/io/bytes.ts +29 -0
  174. package/src/io/cached.ts +136 -0
  175. package/src/io/http.ts +299 -0
  176. package/src/io/read.ts +152 -0
  177. package/src/io/source.ts +87 -0
  178. package/src/node.ts +138 -0
  179. package/src/record-index.ts +389 -0
  180. package/src/recording.ts +234 -0
  181. package/src/tal/annotations.ts +574 -0
  182. package/src/tal/grammar.ts +540 -0
  183. package/src/tal/ticks.ts +172 -0
  184. package/src/time/segments.ts +109 -0
  185. package/src/time/timeline.ts +290 -0
  186. package/src/time/window.ts +304 -0
  187. package/src/types.ts +490 -0
  188. package/src/validate.ts +610 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * De-interleaving and sign extension.
3
+ *
4
+ * Layer 3. Sole owner of turning record bytes into sample values: the 2- and 3-byte two's
5
+ * complement expressions exist here and nowhere else in edfcore.
6
+ *
7
+ * Bitwise operators appear in this file and are correct here — a sample is 16 or 24 bits, so
8
+ * `|` and `<<` are exact on it. They are never applied to an OFFSET: a data offset in a
9
+ * multi-gigabyte BDF routinely exceeds 2^31, where every bitwise operator silently wraps it
10
+ * negative. Every offset below is plain arithmetic, exact to 2^53.
11
+ */
12
+
13
+ import { DEFAULT_MAX_MATERIALIZE_BYTES } from '../constants.js';
14
+ import { EdfBudgetError, EdfChannelNotFoundError, EdfRangeError } from '../errors.js';
15
+ import type { EdfHeader, EdfSignal, RecordRange } from '../types.js';
16
+
17
+ const BYTES_PER_INT32 = 4;
18
+
19
+ /**
20
+ * The allocation ceiling for a decode that has to allocate.
21
+ *
22
+ * A trailing optional argument on functions whose signature is otherwise fixed by the public
23
+ * API, so passing nothing keeps the documented call shape and the 256 MiB default.
24
+ */
25
+ export interface MaterializeOptions {
26
+ readonly maxMaterializeBytes?: number;
27
+ }
28
+
29
+ export interface DecodedDigital {
30
+ readonly digital: Int32Array;
31
+ /**
32
+ * Samples outside the DECLARED digital range, counted in the same pass that decodes them so
33
+ * `EdfChunkSignal.outOfDigitalRangeCount` costs nothing. edfcore never clamps: a non-zero
34
+ * count means the declared range is wrong, not that the samples are.
35
+ */
36
+ readonly outOfDigitalRangeCount: number;
37
+ }
38
+
39
+ /** Hoisted out of the two decode loops so neither reloads a property per sample. */
40
+ interface DeinterleavePlan {
41
+ readonly recordCount: number;
42
+ readonly recordByteLength: number;
43
+ readonly recordByteOffset: number;
44
+ readonly samplesPerRecord: number;
45
+ readonly digitalLow: number;
46
+ readonly digitalHigh: number;
47
+ }
48
+
49
+ function assertWithinBudget(
50
+ requiredBytes: number,
51
+ what: string,
52
+ options: MaterializeOptions | undefined,
53
+ ): void {
54
+ const budgetBytes = options?.maxMaterializeBytes ?? DEFAULT_MAX_MATERIALIZE_BYTES;
55
+ if (requiredBytes <= budgetBytes) return;
56
+ throw new EdfBudgetError(
57
+ `Decoding ${what} needs a ${requiredBytes}-byte array, above the ${budgetBytes}-byte ` +
58
+ 'maxMaterializeBytes budget, so the allocation was refused before it was attempted. ' +
59
+ 'Next: decode fewer records per call, reuse an `out` array, or raise ' +
60
+ 'options.maxMaterializeBytes.',
61
+ { requiredBytes, budgetBytes },
62
+ );
63
+ }
64
+
65
+ function signalAt(header: EdfHeader, signalIndex: number): EdfSignal {
66
+ const signal = header.signals[signalIndex];
67
+ if (signal !== undefined) return signal;
68
+ throw new EdfChannelNotFoundError(
69
+ `signalIndex ${signalIndex} is not one of the ${header.signals.length} signals in this ` +
70
+ 'header. Next: pass an index taken from header.signals, or resolve one with ' +
71
+ 'getSignal(header, label).',
72
+ { selector: signalIndex, availableLabels: header.signals.map((s) => s.label) },
73
+ );
74
+ }
75
+
76
+ /**
77
+ * `recordBytes` must be exactly the requested records and nothing else.
78
+ *
79
+ * Both halves matter. The range check catches a caller asking for records the file does not
80
+ * have; the length check catches a buffer that does not start where `records.start` says it
81
+ * does — which is unrecoverable rather than merely wrong, because nothing in the bytes
82
+ * identifies which record they came from.
83
+ */
84
+ function assertRecordRange(header: EdfHeader, recordBytes: Uint8Array, records: RecordRange): void {
85
+ const available: RecordRange = { start: 0, count: header.recordCount };
86
+ const startValid = Number.isSafeInteger(records.start) && records.start >= 0;
87
+ const countValid = Number.isSafeInteger(records.count) && records.count >= 0;
88
+ if (!startValid || !countValid || records.start + records.count > header.recordCount) {
89
+ throw new EdfRangeError(
90
+ `records { start: ${records.start}, count: ${records.count} } is not inside the ` +
91
+ `${header.recordCount} data records this file contains. Next: clamp the range against ` +
92
+ 'header.recordCount before decoding.',
93
+ { requested: records, available },
94
+ );
95
+ }
96
+
97
+ const expectedBytes = records.count * header.recordByteLength;
98
+ if (recordBytes.length === expectedBytes) return;
99
+ const wholeRecords =
100
+ header.recordByteLength > 0 ? Math.floor(recordBytes.length / header.recordByteLength) : 0;
101
+ throw new EdfRangeError(
102
+ `recordBytes is ${recordBytes.length} bytes, but ${records.count} records of ` +
103
+ `${header.recordByteLength} bytes each are exactly ${expectedBytes}. decodeDigital ` +
104
+ 'cannot tell which record a differently sized buffer begins at, so it will not guess. ' +
105
+ 'Next: pass the buffer returned by readRecordBytes(source, header, records) unmodified.',
106
+ { requested: records, available: { start: records.start, count: wholeRecords } },
107
+ );
108
+ }
109
+
110
+ /**
111
+ * A plain `RangeError`, deliberately not `EdfRangeError`: a signal block that overflows its own
112
+ * record means the header's offset arithmetic is inconsistent with its record size, which is an
113
+ * edfcore bug or a hand-built header — never the caller asking for something reasonable.
114
+ */
115
+ function assertSignalFitsRecord(header: EdfHeader, signal: EdfSignal, blockBytes: number): void {
116
+ if (signal.recordByteOffset + blockBytes <= header.recordByteLength) return;
117
+ throw new RangeError(
118
+ `signal ${signal.index} occupies bytes [${signal.recordByteOffset}, ` +
119
+ `${signal.recordByteOffset + blockBytes}) of a ${header.recordByteLength}-byte record, ` +
120
+ 'which does not fit. The header this was called with is internally inconsistent.',
121
+ );
122
+ }
123
+
124
+ /**
125
+ * The destination for `sampleCount` samples: `out` when it is large enough, otherwise a fresh
126
+ * array checked against the budget first.
127
+ *
128
+ * A longer `out` is narrowed with `subarray`, which shares its memory — the zero-allocation
129
+ * path survives — while keeping `result.length` equal to the true sample count, so no caller
130
+ * can mistake spare capacity for data.
131
+ */
132
+ function resolveOut(
133
+ out: Int32Array | undefined,
134
+ sampleCount: number,
135
+ options: MaterializeOptions | undefined,
136
+ ): Int32Array {
137
+ if (out === undefined) {
138
+ assertWithinBudget(sampleCount * BYTES_PER_INT32, `${sampleCount} digital samples`, options);
139
+ return new Int32Array(sampleCount);
140
+ }
141
+ if (out.length < sampleCount) {
142
+ throw new RangeError(
143
+ `out holds ${out.length} samples but this decode produces ${sampleCount}. Next: size the ` +
144
+ 'reused array to records.count * signal.samplesPerRecord, or omit it and let ' +
145
+ 'decodeDigital allocate.',
146
+ );
147
+ }
148
+ return out.length === sampleCount ? out : out.subarray(0, sampleCount);
149
+ }
150
+
151
+ /** EDF: little-endian 16-bit two's complement. Returns the out-of-declared-range count. */
152
+ function decodeInt16(bytes: Uint8Array, out: Int32Array, plan: DeinterleavePlan): number {
153
+ const { recordCount, recordByteLength, recordByteOffset, samplesPerRecord } = plan;
154
+ const low = plan.digitalLow;
155
+ const high = plan.digitalHigh;
156
+ let outOfRange = 0;
157
+ let write = 0;
158
+ for (let r = 0; r < recordCount; r++) {
159
+ const base = r * recordByteLength + recordByteOffset;
160
+ for (let k = 0; k < samplesPerRecord; k++) {
161
+ const p = base + k * 2;
162
+ // In bounds by construction: assertRecordRange pinned bytes.length to
163
+ // recordCount * recordByteLength, and assertSignalFitsRecord pinned this block inside a
164
+ // record. A per-sample undefined check would cost more than the decode itself.
165
+ // biome-ignore lint/style/noNonNullAssertion: bounds are established above.
166
+ let value = bytes[p]! | (bytes[p + 1]! << 8);
167
+ if (value & 0x8000) value -= 0x10000;
168
+ if (value < low || value > high) outOfRange++;
169
+ out[write] = value;
170
+ write++;
171
+ }
172
+ }
173
+ return outOfRange;
174
+ }
175
+
176
+ /** BDF: little-endian 24-bit two's complement, sign-extended from bit 23. */
177
+ function decodeInt24(bytes: Uint8Array, out: Int32Array, plan: DeinterleavePlan): number {
178
+ const { recordCount, recordByteLength, recordByteOffset, samplesPerRecord } = plan;
179
+ const low = plan.digitalLow;
180
+ const high = plan.digitalHigh;
181
+ let outOfRange = 0;
182
+ let write = 0;
183
+ for (let r = 0; r < recordCount; r++) {
184
+ const base = r * recordByteLength + recordByteOffset;
185
+ for (let k = 0; k < samplesPerRecord; k++) {
186
+ const p = base + k * 3;
187
+ // biome-ignore lint/style/noNonNullAssertion: see decodeInt16.
188
+ let value = bytes[p]! | (bytes[p + 1]! << 8) | (bytes[p + 2]! << 16);
189
+ if (value & 0x800000) value -= 0x1000000;
190
+ if (value < low || value > high) outOfRange++;
191
+ out[write] = value;
192
+ write++;
193
+ }
194
+ }
195
+ return outOfRange;
196
+ }
197
+
198
+ /**
199
+ * Decode one signal out of a record range, with the out-of-declared-range tally.
200
+ *
201
+ * The count compares against `min`/`max` of the DECLARED digital minimum and maximum rather
202
+ * than against the pair as written: an inverted declaration (`digitalMinimum > digitalMaximum`)
203
+ * would otherwise report every sample in the file as out of range, which tells the caller
204
+ * nothing about the samples.
205
+ */
206
+ export function decodeDigitalCounted(
207
+ header: EdfHeader,
208
+ recordBytes: Uint8Array,
209
+ records: RecordRange,
210
+ signalIndex: number,
211
+ out?: Int32Array,
212
+ options?: MaterializeOptions,
213
+ ): DecodedDigital {
214
+ const signal = signalAt(header, signalIndex);
215
+ assertRecordRange(header, recordBytes, records);
216
+
217
+ const bytesPerSample = header.bytesPerSample;
218
+ const samplesPerRecord = signal.samplesPerRecord;
219
+ assertSignalFitsRecord(header, signal, samplesPerRecord * bytesPerSample);
220
+
221
+ const sampleCount = records.count * samplesPerRecord;
222
+ const digital = resolveOut(out, sampleCount, options);
223
+ const plan: DeinterleavePlan = {
224
+ recordCount: records.count,
225
+ recordByteLength: header.recordByteLength,
226
+ // `records.start` located the bytes; inside the buffer record 0 is at offset 0.
227
+ recordByteOffset: signal.recordByteOffset,
228
+ samplesPerRecord,
229
+ digitalLow: Math.min(signal.digitalMinimum, signal.digitalMaximum),
230
+ digitalHigh: Math.max(signal.digitalMinimum, signal.digitalMaximum),
231
+ };
232
+
233
+ const outOfDigitalRangeCount =
234
+ bytesPerSample === 2
235
+ ? decodeInt16(recordBytes, digital, plan)
236
+ : decodeInt24(recordBytes, digital, plan);
237
+
238
+ return { digital, outOfDigitalRangeCount };
239
+ }
240
+
241
+ /**
242
+ * `recordBytes` must be exactly `records.count * header.recordByteLength` bytes and must begin
243
+ * at record `records.start`; anything else throws `EdfRangeError`. `out` is reused when
244
+ * supplied and long enough.
245
+ *
246
+ * The out-of-range tally reaches callers through `EdfChunkSignal.outOfDigitalRangeCount` — it is the same single
247
+ * pass, so a caller never needs a second one to produce it.
248
+ */
249
+ export function decodeDigital(
250
+ header: EdfHeader,
251
+ recordBytes: Uint8Array,
252
+ records: RecordRange,
253
+ signalIndex: number,
254
+ out?: Int32Array,
255
+ options?: MaterializeOptions,
256
+ ): Int32Array {
257
+ return decodeDigitalCounted(header, recordBytes, records, signalIndex, out, options).digital;
258
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Digital counts to physical units.
3
+ *
4
+ * Layer 3. Sole owner of the scaling expression. The expression is PINNED — see `toPhysical`
5
+ * before changing anything in this file.
6
+ *
7
+ * Also home to `clampToDigitalRange`, which is a cross-validation tool and never part of a
8
+ * read: EDFlib clamps silently when it loads samples, edfcore does not, and reproducing that
9
+ * behaviour has to be something a caller asks for explicitly.
10
+ */
11
+
12
+ import { DEFAULT_MAX_MATERIALIZE_BYTES } from '../constants.js';
13
+ import { EdfBudgetError, EdfScalingError } from '../errors.js';
14
+ import type { EdfDiagnosticCode, EdfSignal } from '../types.js';
15
+ import type { MaterializeOptions } from './digital.js';
16
+
17
+ export type { MaterializeOptions } from './digital.js';
18
+
19
+ const BYTES_PER_FLOAT64 = 8;
20
+ const BYTES_PER_INT32 = 4;
21
+
22
+ /** The physical dimension that marks a log-compressed channel (EDFlib `edffloat.html`). */
23
+ const LOG_TRANSFORMED_DIMENSION = 'Filtered';
24
+
25
+ interface ScalingFailure {
26
+ readonly code: EdfDiagnosticCode;
27
+ /** Completes the sentence `signal 7 "EMG Chin" ...`. */
28
+ readonly reason: string;
29
+ readonly specReference: string | undefined;
30
+ }
31
+
32
+ function assertWithinBudget(
33
+ requiredBytes: number,
34
+ what: string,
35
+ options: MaterializeOptions | undefined,
36
+ ): void {
37
+ const budgetBytes = options?.maxMaterializeBytes ?? DEFAULT_MAX_MATERIALIZE_BYTES;
38
+ if (requiredBytes <= budgetBytes) return;
39
+ throw new EdfBudgetError(
40
+ `Producing ${what} needs a ${requiredBytes}-byte array, above the ${budgetBytes}-byte ` +
41
+ 'maxMaterializeBytes budget, so the allocation was refused before it was attempted. ' +
42
+ 'Next: convert fewer samples per call, reuse an `out` array, or raise ' +
43
+ 'options.maxMaterializeBytes.',
44
+ { requiredBytes, budgetBytes },
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Why `signal.scale` is `undefined`, re-derived from the signal itself.
50
+ *
51
+ * The header records the matching diagnostic at parse time, but a bare `EdfSignal` does not
52
+ * carry it, and `toPhysical` takes a signal. These four tests are the same ones `header/scale.ts`
53
+ * applies, IN THE SAME ORDER — a signal can fail two of them at once, and the order is what
54
+ * decides which cause is named, so the code reported here is the code the header reported.
55
+ * `buildScale` owns that order; this function follows it. A signal that fails none
56
+ * of them yet still has no scale gets `SCALE_UNAVAILABLE` rather than the nearest-looking code:
57
+ * naming the wrong cause is worse than admitting the cause is not on hand.
58
+ */
59
+ function describeScalingFailure(signal: EdfSignal): ScalingFailure {
60
+ const digitalSpec =
61
+ 'EDF+ additional specification 5: "Digital maximum must be larger than Digital minimum"';
62
+ if (signal.digitalMinimum === signal.digitalMaximum) {
63
+ return {
64
+ code: 'DEGENERATE_DIGITAL_RANGE',
65
+ reason:
66
+ `declares digitalMinimum == digitalMaximum == ${signal.digitalMinimum}, which makes ` +
67
+ 'the gain a division by zero',
68
+ specReference: digitalSpec,
69
+ };
70
+ }
71
+ if (signal.physicalMinimum === signal.physicalMaximum) {
72
+ return {
73
+ code: 'DEGENERATE_PHYSICAL_RANGE',
74
+ reason:
75
+ `declares physicalMinimum == physicalMaximum == ${signal.physicalMinimum}, so every ` +
76
+ 'sample would map to that one value',
77
+ specReference: undefined,
78
+ };
79
+ }
80
+ if (signal.digitalMinimum > signal.digitalMaximum) {
81
+ return {
82
+ code: 'INVERTED_DIGITAL_RANGE',
83
+ reason:
84
+ `declares digitalMinimum ${signal.digitalMinimum} above digitalMaximum ` +
85
+ `${signal.digitalMaximum}, and edfcore will not guess which one the writer meant`,
86
+ specReference: digitalSpec,
87
+ };
88
+ }
89
+ if (signal.physicalDimension.trim() === LOG_TRANSFORMED_DIMENSION) {
90
+ return {
91
+ code: 'LOG_TRANSFORMED_CHANNEL',
92
+ reason:
93
+ `has physical dimension "${LOG_TRANSFORMED_DIMENSION}", so its values are ` +
94
+ 'log-compressed and the linear formula would be wrong by orders of magnitude',
95
+ specReference: 'EDFlib edffloat.html',
96
+ };
97
+ }
98
+ return {
99
+ code: 'SCALE_UNAVAILABLE',
100
+ reason: 'has no usable scale, and the header recorded the reason rather than the signal',
101
+ specReference: undefined,
102
+ };
103
+ }
104
+
105
+ function scalingError(signal: EdfSignal): EdfScalingError {
106
+ const failure = describeScalingFailure(signal);
107
+ const message =
108
+ `[${failure.code}] signal ${signal.index} "${signal.label}" ${failure.reason}, so ` +
109
+ 'physical units are undefined for it. Raw fields: digital minimum ' +
110
+ `"${signal.raw.digitalMinimum}", digital maximum "${signal.raw.digitalMaximum}", ` +
111
+ `physical minimum "${signal.raw.physicalMinimum}", physical maximum ` +
112
+ `"${signal.raw.physicalMaximum}", physical dimension "${signal.raw.physicalDimension}". ` +
113
+ (failure.specReference === undefined ? '' : `${failure.specReference}. `) +
114
+ 'Next: decodeDigital() still works on this signal; edfcore will not invent a gain.';
115
+ return new EdfScalingError(message, {
116
+ code: failure.code,
117
+ signalIndex: signal.index,
118
+ label: signal.label,
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Float64 by contract, never Float32.
124
+ *
125
+ * Float32 carries 24 significand bits, so a 24-bit BDF sample scaled into it loses about a
126
+ * quarter of a quantisation step — a rounding error a quarter the size of the smallest real
127
+ * difference the hardware can express.
128
+ */
129
+ function resolveFloat64Out(
130
+ out: Float64Array | undefined,
131
+ length: number,
132
+ options: MaterializeOptions | undefined,
133
+ ): Float64Array {
134
+ if (out === undefined) {
135
+ assertWithinBudget(length * BYTES_PER_FLOAT64, `${length} physical samples`, options);
136
+ return new Float64Array(length);
137
+ }
138
+ if (out.length < length) {
139
+ throw new RangeError(
140
+ `out holds ${out.length} samples but this conversion produces ${length}. Next: size the ` +
141
+ 'reused array to digital.length, or omit it and let toPhysical allocate.',
142
+ );
143
+ }
144
+ // A longer `out` is narrowed to a view over its own memory, so reuse still allocates no
145
+ // samples while `result.length` stays equal to the real sample count.
146
+ return out.length === length ? out : out.subarray(0, length);
147
+ }
148
+
149
+ function resolveInt32Out(
150
+ out: Int32Array | undefined,
151
+ length: number,
152
+ options: MaterializeOptions | undefined,
153
+ ): Int32Array {
154
+ if (out === undefined) {
155
+ assertWithinBudget(length * BYTES_PER_INT32, `${length} clamped samples`, options);
156
+ return new Int32Array(length);
157
+ }
158
+ if (out.length < length) {
159
+ throw new RangeError(
160
+ `out holds ${out.length} samples but this clamp produces ${length}. Next: size the ` +
161
+ 'reused array to digital.length, or omit it and let clampToDigitalRange allocate.',
162
+ );
163
+ }
164
+ return out.length === length ? out : out.subarray(0, length);
165
+ }
166
+
167
+ /**
168
+ * `physical = bitValue * (offset + digital)`, in float64 throughout.
169
+ *
170
+ * THIS EXPRESSION IS PINNED AND MUST NOT BE "SIMPLIFIED". It is numerically worse than
171
+ * `physicalMinimum + (digital - digitalMinimum) * gain`, and that is not an accident: it is
172
+ * EDFlib's exact form, kept verbatim so edfcore reproduces pyEDFlib/EDFlib float64 output
173
+ * bit for bit. The two forms disagree by up to ~9.3e-10 LSB — ten orders of magnitude below
174
+ * the quantisation floor, and on asymmetric ranges they differ on nearly half the samples by
175
+ * one ULP. Rewriting this line breaks the golden-value tests, and rightly so.
176
+ *
177
+ * Throws `EdfScalingError` when `signal.scale` is `undefined`. edfcore never fabricates a gain,
178
+ * and `decodeDigital` keeps working on such a signal.
179
+ */
180
+ export function toPhysical(
181
+ signal: EdfSignal,
182
+ digital: ArrayLike<number>,
183
+ out?: Float64Array,
184
+ options?: MaterializeOptions,
185
+ ): Float64Array {
186
+ const scale = signal.scale;
187
+ if (scale === undefined) throw scalingError(signal);
188
+
189
+ const length = digital.length;
190
+ const physical = resolveFloat64Out(out, length, options);
191
+ const bitValue = scale.bitValue;
192
+ const offset = scale.offset;
193
+ for (let i = 0; i < length; i++) {
194
+ // biome-ignore lint/style/noNonNullAssertion: i < digital.length, per the ArrayLike contract.
195
+ physical[i] = bitValue * (offset + digital[i]!);
196
+ }
197
+ return physical;
198
+ }
199
+
200
+ /**
201
+ * Clamp to the declared digital range. POST-HOC ONLY — nothing on the read path calls this.
202
+ *
203
+ * It exists to reproduce a clamping consumer (EDFlib clamps silently on read; edfcore does not)
204
+ * when cross-validating against one. Clamping to `[min(digMin, digMax), max(digMin, digMax)]`
205
+ * rather than to `[digMin, digMax]` matters for an inverted declaration, where the naive bounds
206
+ * are empty and collapse every sample onto a single value.
207
+ */
208
+ export function clampToDigitalRange(
209
+ signal: EdfSignal,
210
+ digital: Int32Array,
211
+ out?: Int32Array,
212
+ options?: MaterializeOptions,
213
+ ): Int32Array {
214
+ const low = Math.min(signal.digitalMinimum, signal.digitalMaximum);
215
+ const high = Math.max(signal.digitalMinimum, signal.digitalMaximum);
216
+ if (!Number.isFinite(low) || !Number.isFinite(high)) {
217
+ // Every comparison against a NaN bound is false, so proceeding would return the input
218
+ // unchanged while claiming to have clamped it.
219
+ throw new RangeError(
220
+ `signal ${signal.index} "${signal.label}" declares digital minimum ` +
221
+ `"${signal.raw.digitalMinimum}" and digital maximum "${signal.raw.digitalMaximum}", ` +
222
+ 'which do not both parse as numbers, so there is no range to clamp to. Next: read ' +
223
+ 'header.diagnostics for this signal.',
224
+ );
225
+ }
226
+
227
+ const length = digital.length;
228
+ const clamped = resolveInt32Out(out, length, options);
229
+ for (let i = 0; i < length; i++) {
230
+ // biome-ignore lint/style/noNonNullAssertion: i < digital.length.
231
+ const value = digital[i]!;
232
+ clamped[i] = value < low ? low : value > high ? high : value;
233
+ }
234
+ return clamped;
235
+ }