/ concept-collection / seqlab
Sign in
concept-collection / seqlab
seqlab / src / seq / parseSeq.ts
436 lines · 12.1 KBBlameHistoryRaw
1// Parser for pulseq .seq files, formats v1.4.x and v1.5.x, mirroring
2// pulseq's +mr/@Sequence/read.m (column layouts, unit scales, shape RLE).
3// Older formats (< 1.4.0) are rejected with a clear message.
4import { md5Hex } from './md5.ts'
5import type {
6 AdcEvent,
7 ArbGradEvent,
8 DefinitionValue,
9 ExtensionRef,
10 ExtensionSpec,
11 GradEvent,
12 ParsedSeq,
13 RfEvent,
14 SeqBlock,
15 SeqVersion,
16 Shape,
17 TrapGradEvent,
18} from './types.ts'
20export class SeqParseError extends Error {}
22const DEFAULT_RASTERS = {
23 blockDuration: 1e-5,
24 gradient: 1e-5,
25 rf: 1e-6,
26 adc: 1e-7,
29function tokens(line: string): string[] {
30 return line.trim().split(/\s+/)
33function nums(line: string, context: string): number[] {
34 const out = tokens(line).map(Number)
35 if (out.some((v) => Number.isNaN(v))) {
36 throw new SeqParseError(`malformed numeric line in ${context}: "${line.trim()}"`)
37 }
38 return out
41/** pulseq shape decompression: RLE on the derivative (v v n => v x(n+2)), then cumsum. */
42export function decompressShape(numSamples: number, data: number[]): Float64Array {
43 if (data.length === numSamples) {
44 // uncompressed (stored) samples — v1.4+ marks this by matching lengths
45 return Float64Array.from(data)
46 }
47 const deriv: number[] = []
48 let i = 0
49 while (i < data.length) {
50 if (i + 2 < data.length && data[i] === data[i + 1]) {
51 const v = data[i]
52 const rep = data[i + 2] + 2
53 for (let k = 0; k < rep; k++) deriv.push(v)
54 i += 3
55 } else {
56 deriv.push(data[i])
57 i++
58 }
59 }
60 if (deriv.length !== numSamples) {
61 throw new SeqParseError(
62 `shape decompression produced ${deriv.length} samples, expected ${numSamples}`,
63 )
64 }
65 const out = new Float64Array(numSamples)
66 let acc = 0
67 for (let k = 0; k < numSamples; k++) {
68 acc += deriv[k]
69 out[k] = acc
70 }
71 return out
74export function parseSeq(text: string): ParsedSeq {
75 const lines = text.split(/\r?\n/)
77 let version: SeqVersion | null = null
78 const definitions = new Map<string, DefinitionValue>()
79 const blocks: SeqBlock[] = []
80 const rf = new Map<number, RfEvent>()
81 const grads = new Map<number, GradEvent>()
82 const adcs = new Map<number, AdcEvent>()
83 const shapes = new Map<number, Shape>()
84 const extensions = new Map<number, ExtensionRef>()
85 const extensionSpecs: ExtensionSpec[] = []
86 let signature: ParsedSeq['signature']
88 // Raw [BLOCKS] rows; converted to seconds once rasters are known (the
89 // [DEFINITIONS] section precedes [BLOCKS] in practice, but don't rely on it).
90 const blockRows: number[][] = []
92 type Section =
93 | 'none'
94 | 'version'
95 | 'definitions'
96 | 'blocks'
97 | 'rf'
98 | 'gradients'
99 | 'trap'
100 | 'adc'
101 | 'shapes'
102 | 'extensions'
103 | 'extension-spec'
104 | 'signature'
105 | 'skip'
106 let section: Section = 'none'
107 let currentSpec: ExtensionSpec | null = null
109 // [SHAPES] accumulation state
110 let shapeId = -1
111 let shapeNumSamples = -1
112 let shapeData: number[] = []
113 const flushShape = () => {
114 if (shapeId < 0) return
115 shapes.set(shapeId, {
116 id: shapeId,
117 numSamples: shapeNumSamples,
118 samples: decompressShape(shapeNumSamples, shapeData),
119 storedSamples: shapeData.length,
120 })
121 shapeId = -1
122 shapeNumSamples = -1
123 shapeData = []
124 }
126 const requireVersion = (): SeqVersion => {
127 if (!version) throw new SeqParseError('file must declare [VERSION] before event sections')
128 return version
129 }
131 for (const rawLine of lines) {
132 const line = rawLine.trim()
133 if (line === '' || line.startsWith('#')) continue
135 if (line.startsWith('[') && line.endsWith(']')) {
136 if (section === 'shapes') flushShape()
137 currentSpec = null
138 switch (line) {
139 case '[VERSION]':
140 section = 'version'
141 version = { major: 0, minor: 0, revision: '0', combined: 0 }
142 break
143 case '[DEFINITIONS]':
144 section = 'definitions'
145 break
146 case '[BLOCKS]':
147 section = 'blocks'
148 break
149 case '[RF]':
150 requireVersion()
151 section = 'rf'
152 break
153 case '[GRADIENTS]':
154 requireVersion()
155 section = 'gradients'
156 break
157 case '[TRAP]':
158 section = 'trap'
159 break
160 case '[ADC]':
161 requireVersion()
162 section = 'adc'
163 break
164 case '[SHAPES]':
165 section = 'shapes'
166 break
167 case '[EXTENSIONS]':
168 section = 'extensions'
169 break
170 case '[SIGNATURE]':
171 section = 'signature'
172 break
173 case '[DELAYS]':
174 throw new SeqParseError(
175 'this file uses the pre-1.4.0 pulseq format ([DELAYS] section), which is not supported',
176 )
177 default:
178 section = 'skip' // unknown section: ignore its lines
179 break
180 }
181 continue
182 }
184 // `extension NAME typeId` opens a specification subsection
185 if ((section === 'extensions' || section === 'extension-spec') && line.startsWith('extension ')) {
186 const t = tokens(line)
187 currentSpec = { name: t[1], typeId: Number(t[2]), rows: [] }
188 extensionSpecs.push(currentSpec)
189 section = 'extension-spec'
190 continue
191 }
193 switch (section) {
194 case 'version': {
195 const [key, value] = tokens(line)
196 const v = version!
197 if (key === 'major') v.major = Number(value)
198 else if (key === 'minor') v.minor = Number(value)
199 else if (key === 'revision') v.revision = value
200 v.combined = 1000000 * v.major + 1000 * v.minor + (parseInt(v.revision, 10) || 0)
201 break
202 }
203 case 'definitions': {
204 const t = tokens(line)
205 const key = t[0]
206 const rest = t.slice(1)
207 const asNums = rest.map(Number)
208 definitions.set(
209 key,
210 rest.length > 0 && asNums.every((v) => !Number.isNaN(v))
211 ? asNums
212 : line.trim().slice(key.length).trim(),
213 )
214 break
215 }
216 case 'blocks':
217 blockRows.push(nums(line, '[BLOCKS]'))
218 break
219 case 'rf': {
220 const v = requireVersion()
221 if (v.combined >= 1005000) {
222 // id amp mag_id phase_id time_id center(us) delay(us) freqPPM phasePPM freq phase use
223 const t = tokens(line)
224 const d = t.slice(0, 11).map(Number)
225 if (d.some(Number.isNaN) || t.length < 12) {
226 throw new SeqParseError(`malformed [RF] line: "${line}"`)
227 }
228 rf.set(d[0], {
229 id: d[0],
230 amp: d[1],
231 magShapeId: d[2],
232 phaseShapeId: d[3],
233 timeShapeId: d[4],
234 center: d[5] * 1e-6,
235 delay: d[6] * 1e-6,
236 freqPPM: d[7],
237 phasePPM: d[8],
238 freq: d[9],
239 phase: d[10],
240 use: t[11],
241 })
242 } else {
243 // v1.4: id amp mag_id phase_id time_id delay(us) freq phase
244 const d = nums(line, '[RF]')
245 rf.set(d[0], {
246 id: d[0],
247 amp: d[1],
248 magShapeId: d[2],
249 phaseShapeId: d[3],
250 timeShapeId: d[4],
251 center: NaN,
252 delay: d[5] * 1e-6,
253 freqPPM: 0,
254 phasePPM: 0,
255 freq: d[6],
256 phase: d[7],
257 use: 'u',
258 })
259 }
260 break
261 }
262 case 'gradients': {
263 const v = requireVersion()
264 const d = nums(line, '[GRADIENTS]')
265 let ev: ArbGradEvent
266 if (v.combined >= 1005000) {
267 // id amp first last shape_id time_id delay(us)
268 ev = {
269 id: d[0],
270 kind: 'grad',
271 amp: d[1],
272 first: d[2],
273 last: d[3],
274 shapeId: d[4],
275 timeShapeId: d[5],
276 delay: d[6] * 1e-6,
277 }
278 } else {
279 // v1.4: id amp shape_id time_id delay(us); first/last derived on demand
280 ev = {
281 id: d[0],
282 kind: 'grad',
283 amp: d[1],
284 first: NaN,
285 last: NaN,
286 shapeId: d[2],
287 timeShapeId: d[3],
288 delay: d[4] * 1e-6,
289 }
290 }
291 grads.set(ev.id, ev)
292 break
293 }
294 case 'trap': {
295 // id amp rise(us) flat(us) fall(us) delay(us)
296 const d = nums(line, '[TRAP]')
297 const ev: TrapGradEvent = {
298 id: d[0],
299 kind: 'trap',
300 amp: d[1],
301 rise: d[2] * 1e-6,
302 flat: d[3] * 1e-6,
303 fall: d[4] * 1e-6,
304 delay: d[5] * 1e-6,
305 }
306 grads.set(ev.id, ev)
307 break
308 }
309 case 'adc': {
310 const v = requireVersion()
311 const d = nums(line, '[ADC]')
312 if (v.combined >= 1005000) {
313 // id num dwell(ns) delay(us) freqPPM phasePPM freq phase phase_id
314 adcs.set(d[0], {
315 id: d[0],
316 num: d[1],
317 dwell: d[2] * 1e-9,
318 delay: d[3] * 1e-6,
319 freqPPM: d[4],
320 phasePPM: d[5],
321 freq: d[6],
322 phase: d[7],
323 phaseShapeId: d[8] ?? 0,
324 })
325 } else {
326 // v1.4: id num dwell(ns) delay(us) freq phase
327 adcs.set(d[0], {
328 id: d[0],
329 num: d[1],
330 dwell: d[2] * 1e-9,
331 delay: d[3] * 1e-6,
332 freqPPM: 0,
333 phasePPM: 0,
334 freq: d[4],
335 phase: d[5],
336 phaseShapeId: 0,
337 })
338 }
339 break
340 }
341 case 'shapes': {
342 const t = tokens(line)
343 if (t[0] === 'shape_id') {
344 flushShape()
345 shapeId = Number(t[1])
346 } else if (t[0] === 'num_samples') {
347 shapeNumSamples = Number(t[1])
348 } else {
349 for (const tok of t) {
350 const v = Number(tok)
351 if (Number.isNaN(v)) throw new SeqParseError(`malformed shape sample: "${line}"`)
352 shapeData.push(v)
353 }
354 }
355 break
356 }
357 case 'extensions': {
358 const d = nums(line, '[EXTENSIONS]')
359 extensions.set(d[0], { id: d[0], type: d[1], ref: d[2], next: d[3] })
360 break
361 }
362 case 'extension-spec':
363 currentSpec!.rows.push(tokens(line))
364 break
365 case 'signature': {
366 const [key, value] = tokens(line)
367 signature = signature ?? { type: '', hash: '' }
368 if (key === 'Type') signature.type = value
369 else if (key === 'Hash') signature.hash = value
370 break
371 }
372 case 'none':
373 case 'skip':
374 break
375 }
376 }
377 if (section === 'shapes') flushShape()
379 if (!version) throw new SeqParseError('not a pulseq file: no [VERSION] section found')
380 if (version.combined < 1004000) {
381 throw new SeqParseError(
382 `pulseq format ${version.major}.${version.minor}.${version.revision} is not supported (1.4.0 or later required)`,
383 )
384 }
386 const rasters = {
387 blockDuration:
388 (definitions.get('BlockDurationRaster') as number[])?.[0] ?? DEFAULT_RASTERS.blockDuration,
389 gradient:
390 (definitions.get('GradientRasterTime') as number[])?.[0] ?? DEFAULT_RASTERS.gradient,
391 rf: (definitions.get('RadiofrequencyRasterTime') as number[])?.[0] ?? DEFAULT_RASTERS.rf,
392 adc: (definitions.get('AdcRasterTime') as number[])?.[0] ?? DEFAULT_RASTERS.adc,
393 }
395 let totalDuration = 0
396 for (const row of blockRows) {
397 // v1.4+: id dur rf gx gy gz adc ext
398 if (row.length < 8) throw new SeqParseError(`malformed [BLOCKS] row (${row.length} columns)`)
399 const duration = row[1] * rasters.blockDuration
400 blocks.push({
401 id: row[0],
402 duration,
403 rfId: row[2],
404 gxId: row[3],
405 gyId: row[4],
406 gzId: row[5],
407 adcId: row[6],
408 extId: row[7],
409 })
410 totalDuration += duration
411 }
413 if (signature?.type?.toLowerCase() === 'md5') {
414 // The hash covers the file up to (excluding) the newline that precedes
415 // [SIGNATURE] — that newline was added together with the section.
416 const idx = text.indexOf('\n[SIGNATURE]')
417 if (idx >= 0) {
418 signature.valid = md5Hex(text.slice(0, idx)) === signature.hash.toLowerCase()
419 }
420 }
422 return {
423 version,
424 definitions,
425 rasters,
426 blocks,
427 rf,
428 grads,
429 adcs,
430 shapes,
431 extensions,
432 extensionSpecs,
433 signature,
434 totalDuration,
435 }
moveopenescclose