dsv2pulseq 0.0.0__py3-none-any.whl

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.
dsv2pulseq/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .read_dsv import read_dsv, check_dsv
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env python
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from dsv2pulseq.read_dsv import read_dsv
7
+
8
+ defaults = {
9
+ 'out_file': 'external.seq',
10
+ 'ref_volt': 223.529007,
11
+ 'lead_time': 100,
12
+ 'hold_time': 30,
13
+ 'adc_dead_time': 10,
14
+ 'fov': [None, None, None],
15
+ }
16
+
17
+ def parse_arguments(argv=None):
18
+ parser = argparse.ArgumentParser(
19
+ description='Create Pulseq sequence file from dsv file.',
20
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter
21
+ )
22
+ parser.add_argument('in_file_prefix', type=str, help="Input dsv file prefix. E.g. 'gre'")
23
+ parser.add_argument('-o', '--out_file', type=str, help='Output Pulseq file.')
24
+ parser.add_argument('-r', '--ref_volt', type=float, help='Reference voltage of simulation [V].')
25
+ parser.add_argument('--lead_time', type=int, help='RF lead time [us].')
26
+ parser.add_argument('--hold_time', type=int, help='RF hold time [us].')
27
+ parser.add_argument('--adc_dead_time', type=int, help='ADC dead time [us].')
28
+ parser.add_argument('--fov', type=float, nargs=3, help='Field of view [mm] for x, y, z.')
29
+ parser.add_argument('--highgain', action='store_true', help='Set receiver gain to high.')
30
+
31
+ parser.set_defaults(**defaults)
32
+ return parser.parse_args(argv)
33
+
34
+ def main(argv=None):
35
+ args = parse_arguments(argv)
36
+
37
+ # check input
38
+ sfx = ['_INF', '_GRX', '_GRY', '_GRZ', '_RFD', '_RFP']
39
+ for suffix in sfx:
40
+ fname = args.in_file_prefix + suffix + '.dsv'
41
+ if not os.path.isfile(fname):
42
+ raise OSError(f"DSV file {fname} does not exist.")
43
+
44
+ seq = read_dsv(args.in_file_prefix, args.ref_volt, plot=False)
45
+ seq.set_lead_hold_time(args.lead_time, args.hold_time)
46
+ seq.set_adc_dead_time(args.adc_dead_time)
47
+ seq.make_pulseq_sequence(args.out_file, fov=args.fov, highgain=args.highgain)
48
+
49
+ if __name__ == "__main__":
50
+ sys.exit(main())
dsv2pulseq/helper.py ADDED
@@ -0,0 +1,56 @@
1
+ """
2
+ Helper functions
3
+ """
4
+
5
+ import numpy as np
6
+ import math
7
+
8
+ def trapezoid(amplitude, rise_time, flat_time, fall_time, dt=1e-5):
9
+ # Time segments
10
+ t_rise = np.arange(dt/2, rise_time, dt)
11
+ t_flat = np.arange(dt/2, flat_time, dt)
12
+ t_fall = np.arange(dt/2, fall_time, dt)
13
+
14
+ # Signal segments
15
+ rise = (amplitude / rise_time) * t_rise
16
+ plateau = np.ones_like(t_flat) * amplitude
17
+ fall = amplitude - (amplitude / fall_time) * t_fall
18
+
19
+ # Concatenate full waveform
20
+ waveform = np.concatenate((rise, plateau, fall))
21
+
22
+ return waveform
23
+
24
+ def waveform_from_seqblock(grad, system=None):
25
+ """
26
+ extracts gradient waveform from Pypulseq sequence block
27
+ """
28
+
29
+ if grad.type == 'trap':
30
+ waveform = trapezoid(grad.amplitude, grad.rise_time, grad.flat_time, grad.fall_time, dt=system.grad_raster_time)
31
+ else:
32
+ waveform = grad.waveform
33
+
34
+ waveform = np.concatenate((np.zeros(int(grad.delay / system.grad_raster_time)), waveform))
35
+
36
+ return waveform
37
+
38
+ def round_up_to_raster(number, decimals=5, tol=1e-10):
39
+ """
40
+ Round number up to a specific number of decimal places.
41
+ Rounds up only if the digit beyond the desired precision exceeds a tolerance.
42
+ This avoids rounding up for tiny floating point errors.
43
+
44
+ Parameters:
45
+ - number: float, the value to round
46
+ - decimals: int, number of decimal places
47
+ - tol: float, minimum excess to consider as real (not floating point noise)
48
+ """
49
+ multiplier = 10 ** decimals
50
+ scaled = number * multiplier
51
+ rounded = math.floor(scaled)
52
+
53
+ if scaled - rounded > tol:
54
+ rounded += 1
55
+
56
+ return rounded / multiplier
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python
2
+
3
+ import argparse
4
+ import twixtools
5
+ import ismrmrd
6
+ import sys
7
+
8
+ defaults = {'out_file': 'merged_rawdata.dat'}
9
+
10
+ def parse_arguments(argv=None):
11
+ parser = argparse.ArgumentParser(
12
+ description='Merge Pulseq data into Siemens raw data file.',
13
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter
14
+ )
15
+ parser.add_argument('in_file_1', type=str, help="Siemens raw data file acquired with original sequence.")
16
+ parser.add_argument('in_file_2', type=str, help="Pulseq raw data file.")
17
+ parser.add_argument('-o', '--out_file', type=str, help='Output Siemens raw data file name.')
18
+
19
+ parser.set_defaults(**defaults)
20
+ return parser.parse_args(argv)
21
+
22
+ def main(argv=None):
23
+ args = parse_arguments(argv)
24
+
25
+ if not args.in_file_1.endswith('.dat'):
26
+ raise ValueError("Input file 1 must be a Siemens raw data file '.dat'.")
27
+ if not (args.in_file_2.endswith('.dat') or args.in_file_2.endswith('.mrd')):
28
+ raise ValueError("Input file 2 (Pulseq data file) must be either a Siemens raw data file '.dat' or ISMRMRD file '.mrd'.")
29
+ use_mrd = args.in_file_2.endswith('.mrd')
30
+
31
+ file1 = twixtools.read_twix(args.in_file_1, keep_syncdata=True, keep_acqend=True)
32
+ n_acq1 = len(file1[-1]['mdb'])
33
+ if use_mrd:
34
+ file2 = ismrmrd.Dataset(args.in_file_2)
35
+ n_acq2 = file2.number_of_acquisitions()
36
+ if n_acq1 - n_acq2 == 1:
37
+ n_acq2 += 1 # ACQEND is usually not converted to MRD
38
+ else:
39
+ file2 = twixtools.read_twix(args.in_file_2, keep_syncdata=True, keep_acqend=True)
40
+ n_acq2 = len(file2[-1]['mdb'])
41
+
42
+ if n_acq1 != n_acq2:
43
+ raise ValueError(f"Files have different number of measurement data blocks. File 1: {n_acq1}, File 2: {n_acq2}.")
44
+
45
+ print(f"Read and copy {n_acq1} measurement data blocks.")
46
+ for k, mdb in enumerate(file1[-1]['mdb']):
47
+ if not mdb.is_flag_set('ACQEND') and not mdb.is_flag_set('SYNCDATA'):
48
+ mdb = mdb.convert_to_local()
49
+ if use_mrd:
50
+ mdb.data = file2.read_acquisition(k).data.copy()
51
+ else:
52
+ mdb.data = file2[-1]['mdb'][k].data.copy()
53
+ file1[-1]['mdb'][k] = mdb
54
+
55
+ if use_mrd:
56
+ file2.close()
57
+
58
+ print(f"Writing merged data to {args.out_file}.")
59
+ twixtools.write_twix(file1, args.out_file, version_is_ve=True)
60
+
61
+ if __name__ == "__main__":
62
+ sys.exit(main())
dsv2pulseq/read_dsv.py ADDED
@@ -0,0 +1,214 @@
1
+ import time
2
+ import numpy as np
3
+ import logging
4
+ import os
5
+ from dsv2pulseq.sequence import Sequence
6
+ from dsv2pulseq.read_dsv_samples import DSVFile
7
+ from dsv2pulseq.read_dsv_inf import read_dsv_inf
8
+
9
+ logging.basicConfig(level=logging.INFO)
10
+
11
+ def plot_seq(dsv):
12
+ """
13
+ Plots the complete sequence
14
+ """
15
+ import matplotlib.pyplot as plt
16
+
17
+ plt.figure(figsize=(20,20))
18
+ plt.subplot(511)
19
+ dsv[0].plot()
20
+ plt.subplot(512)
21
+ dsv[1].plot()
22
+ plt.subplot(513)
23
+ dsv[2].plot()
24
+ plt.subplot(514)
25
+ dsv[3].plot()
26
+ plt.subplot(515)
27
+ dsv[4].plot()
28
+
29
+ def read_dsv(file_prefix, ref_volt=223.529007, plot=False):
30
+ """
31
+ Reads dsv files and returns complete sequence
32
+
33
+ Parameters:
34
+ file_prefix: dsv file prefix, e.g. 'gre'
35
+ ref_volt: reference voltage of the simulation in V
36
+ plot: if True, plots the sequence shapes
37
+ Returns:
38
+ seq: Sequence object containing the shapes and blocks
39
+ """
40
+ seq = Sequence(ref_volt)
41
+
42
+ # read event shapes
43
+ logging.info(f"Read RF and gradient shapes of {file_prefix} sequence.")
44
+ start_dsv = time.time()
45
+ rfd = DSVFile(file_prefix+"_RFD.dsv")
46
+ rfp = DSVFile(file_prefix+"_RFP.dsv")
47
+ grx = DSVFile(file_prefix+"_GRX.dsv")
48
+ gry = DSVFile(file_prefix+"_GRY.dsv")
49
+ grz = DSVFile(file_prefix+"_GRZ.dsv")
50
+ end_dsv = time.time()
51
+ logging.info(f"Finished reading dsv files in {(end_dsv-start_dsv):.2f}s.")
52
+
53
+ shapes = [rfd, rfp, grx, gry, grz]
54
+ seq.set_shapes(shapes)
55
+
56
+ # Read block structure
57
+ logging.info("Read block structure.")
58
+ start_inf = time.time()
59
+ read_dsv_inf(file_prefix+"_INF.dsv", seq)
60
+ end_inf = time.time()
61
+ logging.info(f"Finished reading block structure in {(end_inf-start_inf):.2f}s.")
62
+
63
+ if plot:
64
+ plot_seq([rfd,rfp,grx,gry,grz])
65
+
66
+ return seq
67
+
68
+ def check_dsv(file_prefix1, file_prefix2, time_shift=20):
69
+ """
70
+ Checks, if dsv files from converted sequence are the same as in the original sequence
71
+
72
+ Pulseq sequence shape usually have two 20us long additional global
73
+ freq/phase event in the beginning and end of the sequence. This can be
74
+ compensated by the time_shift parameter.
75
+
76
+ If provided, also the ADC shapes are compared.
77
+
78
+ Parameters:
79
+ file_prefix1: dsv file prefix of the original sequence
80
+ file_prefix2: dsv file prefix of the Pulseq sequence
81
+ time_shift: time shift in us for the Pulseq sequence
82
+
83
+ Returns:
84
+ seq1: Sequence object of the original sequence
85
+ seq2: Sequence object of the Pulseq sequence
86
+ """
87
+ import matplotlib.pyplot as plt
88
+
89
+ seq1 = read_dsv(file_prefix1)
90
+ seq2 = read_dsv(file_prefix2)
91
+
92
+ if os.path.exists(file_prefix1 + "_ADC.dsv") and os.path.exists(file_prefix2 + "_ADC.dsv"):
93
+ adc1_dsv = DSVFile(file_prefix1 + "_ADC.dsv")
94
+ adc2_dsv = DSVFile(file_prefix2 + "_ADC.dsv")
95
+ adc_delta = adc1_dsv.definitions.horidelta
96
+ seq1.adc = adc1_dsv.values
97
+ seq2.adc = adc2_dsv.values
98
+ else:
99
+ seq1.adc = None
100
+ seq2.adc = None
101
+
102
+ rfd1 = abs(seq1.rf)
103
+ rfp1 = np.angle(seq1.rf)
104
+ grx1 = seq1.gx
105
+ gry1 = seq1.gy
106
+ grz1 = seq1.gz
107
+ adc1 = seq1.adc
108
+
109
+ rfd2 = abs(seq2.rf)
110
+ rfp2 = np.angle(seq2.rf)
111
+ grx2 = seq2.gx
112
+ gry2 = seq2.gy
113
+ grz2 = seq2.gz
114
+ adc2 = seq2.adc
115
+
116
+ shift_rf = int(time_shift / seq1.delta_rf)
117
+ shift_grad = int(time_shift / seq1.delta_grad)
118
+
119
+ rfd2 = rfd2[shift_rf:-shift_rf]
120
+ rfp2 = rfp2[shift_rf:-shift_rf]
121
+ grx2 = grx2[shift_grad:-shift_grad]
122
+ gry2 = gry2[shift_grad:-shift_grad]
123
+ grz2 = grz2[shift_grad:-shift_grad]
124
+ if adc1 is not None and adc2 is not None:
125
+ shift_adc = int(time_shift / adc_delta)
126
+ adc2 = adc2[shift_adc:-shift_adc]
127
+ subplots = 6
128
+ else:
129
+ subplots = 5
130
+
131
+ len_rf = min(len(rfd1), len(rfd2))
132
+ len_gx = min(len(grx1), len(grx2))
133
+ len_gy = min(len(gry1), len(gry2))
134
+ len_gz = min(len(grz1), len(grz2))
135
+ if adc1 is not None and adc2 is not None:
136
+ len_adc = min(len(adc1), len(adc2))
137
+
138
+ # Plot sequence 1
139
+ n_subplots = subplots # 5 or 6
140
+ plt.figure(figsize=(10, 2 * n_subplots))
141
+ plt.suptitle(f"Sequence 1: {file_prefix1}", fontsize=16)
142
+ plt.subplot(n_subplots, 1, 1)
143
+ plt.plot(rfd1)
144
+ plt.title("RFD")
145
+ plt.subplot(n_subplots, 1, 2)
146
+ plt.plot(rfp1)
147
+ plt.title("RFP")
148
+ plt.subplot(n_subplots, 1, 3)
149
+ plt.plot(grx1)
150
+ plt.title("GX")
151
+ plt.subplot(n_subplots, 1, 4)
152
+ plt.plot(gry1)
153
+ plt.title("GY")
154
+ plt.subplot(n_subplots, 1, 5)
155
+ plt.plot(grz1)
156
+ plt.title("GZ")
157
+ if n_subplots == 6:
158
+ plt.subplot(n_subplots, 1, 6)
159
+ plt.plot(adc1)
160
+ plt.title("ADC")
161
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
162
+
163
+ # Plot sequence 2
164
+ plt.figure(figsize=(10, 2 * n_subplots))
165
+ plt.suptitle(f"Sequence 2: {file_prefix2}", fontsize=16)
166
+ plt.subplot(n_subplots, 1, 1)
167
+ plt.plot(rfd2)
168
+ plt.title("RFD")
169
+ plt.subplot(n_subplots, 1, 2)
170
+ plt.plot(rfp2)
171
+ plt.title("RFP")
172
+ plt.subplot(n_subplots, 1, 3)
173
+ plt.plot(grx2)
174
+ plt.title("GX")
175
+ plt.subplot(n_subplots, 1, 4)
176
+ plt.plot(gry2)
177
+ plt.title("GY")
178
+ plt.subplot(n_subplots, 1, 5)
179
+ plt.plot(grz2)
180
+ plt.title("GZ")
181
+ if n_subplots == 6:
182
+ plt.subplot(n_subplots, 1, 6)
183
+ plt.plot(adc2)
184
+ plt.title("ADC")
185
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
186
+
187
+ # Plot difference
188
+ plt.figure(figsize=(10, 2 * n_subplots))
189
+ plt.suptitle(f"Difference: {file_prefix1} - {file_prefix2}", fontsize=16)
190
+ plt.subplot(n_subplots, 1, 1)
191
+ plt.plot(rfd1[:len_rf] - rfd2[:len_rf])
192
+ plt.title("RFD")
193
+ plt.subplot(n_subplots, 1, 2)
194
+ plt.plot(rfp1[:len_rf] - rfp2[:len_rf])
195
+ plt.title("RFP")
196
+ plt.subplot(n_subplots, 1, 3)
197
+ plt.plot(grx1[:len_gx] - grx2[:len_gx])
198
+ plt.title("GX")
199
+ plt.subplot(n_subplots, 1, 4)
200
+ plt.plot(gry1[:len_gy] - gry2[:len_gy])
201
+ plt.title("GY")
202
+ plt.subplot(n_subplots, 1, 5)
203
+ plt.plot(grz1[:len_gz] - grz2[:len_gz])
204
+ plt.title("GZ")
205
+ if n_subplots == 6:
206
+ plt.subplot(n_subplots, 1, 6)
207
+ plt.plot(adc1[:len_adc] - adc2[:len_adc])
208
+ plt.title("ADC")
209
+ plt.tight_layout(rect=[0, 0, 1, 0.96])
210
+
211
+ plt.show()
212
+
213
+ return seq1, seq2
214
+
@@ -0,0 +1,94 @@
1
+ """
2
+ Read a dsv INF file and append blocks to sequence
3
+ """
4
+
5
+ def find_char(s, ch):
6
+ return [i for i, ltr in enumerate(s) if ltr == ch]
7
+
8
+ def read_dsv_inf(file, seq):
9
+ """ Read INF dsv file (tested only for VE)
10
+
11
+ Important: The INF file contains the gradients in logical coordinate system (GP,GR,GS)
12
+ """
13
+
14
+ delta_rf = seq.delta_rf
15
+ delta_grad = seq.delta_grad
16
+
17
+ with open(file, 'r') as f:
18
+ line_ix = float('inf')
19
+ block_idx = -1
20
+ for k,line in enumerate(f):
21
+ if 'EventBlock' in line:
22
+ block_idx = seq.n_blocks
23
+ char_ix = find_char(line, ' ')
24
+ start_time = int(line[char_ix[-2]:char_ix[-1]])
25
+ duration = int(line[char_ix[-1]:])
26
+ seq.add_block(block_idx, duration, start_time)
27
+ if 'Rel.Time' in line:
28
+ ix = find_char(line, '|') # position of events in line
29
+ line_ix = k+2
30
+ ts_old = 0
31
+ freq_phase = None
32
+ if '|' not in line:
33
+ # end of event block
34
+ line_ix = float('inf')
35
+ ts = -1
36
+ if k>=line_ix:
37
+ block = seq.get_block(block_idx)
38
+ ts = int(line[ix[0]+1:ix[1]].strip())
39
+ block.add_timestamp(ts)
40
+
41
+ if ts != ts_old and freq_phase is not None:
42
+ block.set_freqphase(freq_phase, ts_old)
43
+ freq_phase = None
44
+
45
+ if line[ix[1]+1:ix[2]].strip():
46
+ rf_str = line[ix[1]+1:ix[2]].strip()
47
+ rf_dur = int(rf_str[rf_str.rfind('/')+1:].strip())
48
+ rf_shape_ix = slice((block.start_time+ts)//delta_rf, (block.start_time+ts+rf_dur)//delta_rf)
49
+ block.add_rf(rf_dur, rf_shape_ix, ts)
50
+ if line[ix[2]+1:ix[3]].strip():
51
+ freqphase_str = line[ix[2]+1:ix[3]].strip()
52
+ freq = float(freqphase_str[freqphase_str.rfind(':')+1:freqphase_str.rfind('/')].strip())
53
+ phase = float(freqphase_str[freqphase_str.rfind('/')+1:].strip())
54
+ freq_phase = [freq, phase]
55
+ if line[ix[4]+1:ix[5]].strip():
56
+ adc_str = line[ix[4]+1:ix[5]].strip()
57
+ adc_samples = float(adc_str[adc_str.rfind(':')+1:adc_str.rfind('/')].strip())
58
+ adc_dur = float(adc_str[adc_str.rfind('/')+1:].strip())
59
+ if "VOP_READOUT" not in adc_str: # pTx RX events during RFs are not ADCs
60
+ block.add_adc(adc_dur, adc_samples, ts)
61
+ if line[ix[5]+1:ix[6]].strip():
62
+ gp_str = line[ix[5]+1:ix[6]].strip()
63
+ gp_ix = find_char(gp_str, '/')
64
+ gp_amp = float(gp_str[gp_str.rfind(':')+1:gp_ix[0]].strip()) # amplitude in logical! coordinate system
65
+ gp_rut = int(gp_str[gp_ix[0]+1:gp_ix[1]].strip())
66
+ gp_dur = int(gp_str[gp_ix[1]+1:gp_ix[2]].strip())
67
+ gp_rdt = int(gp_str[gp_ix[2]+1:].strip())
68
+ gp_shape_ix = slice((block.start_time+ts)//delta_grad, (block.start_time+ts+gp_dur+gp_rdt)//delta_grad)
69
+ block.add_grad('p', gp_amp, gp_dur, gp_rut, gp_rdt, gp_shape_ix, ts)
70
+ if line[ix[6]+1:ix[7]].strip():
71
+ gr_str = line[ix[6]+1:ix[7]].strip()
72
+ gr_ix = find_char(gr_str, '/')
73
+ gr_amp = float(gr_str[gr_str.rfind(':')+1:gr_ix[0]].strip())
74
+ gr_rut = int(gr_str[gr_ix[0]+1:gr_ix[1]].strip())
75
+ gr_dur = int(gr_str[gr_ix[1]+1:gr_ix[2]].strip())
76
+ gr_rdt = int(gr_str[gr_ix[2]+1:].strip())
77
+ gr_shape_ix = slice((block.start_time+ts)//delta_grad, (block.start_time+ts+gr_dur+gr_rdt)//delta_grad)
78
+ block.add_grad('r', gr_amp, gr_dur, gr_rut, gr_rdt, gr_shape_ix, ts)
79
+ if line[ix[7]+1:ix[8]].strip():
80
+ gs_str = line[ix[7]+1:ix[8]].strip()
81
+ gs_ix = find_char(gs_str, '/')
82
+ gs_amp = float(gs_str[gs_str.rfind(':')+1:gs_ix[0]].strip())
83
+ gs_rut = int(gs_str[gs_ix[0]+1:gs_ix[1]].strip())
84
+ gs_dur = int(gs_str[gs_ix[1]+1:gs_ix[2]].strip())
85
+ gs_rdt = int(gs_str[gs_ix[2]+1:].strip())
86
+ gs_shape_ix = slice((block.start_time+ts)//delta_grad, (block.start_time+ts+gs_dur+gs_rdt)//delta_grad)
87
+ block.add_grad('s', gs_amp, gs_dur, gs_rut, gs_rdt, gs_shape_ix, ts)
88
+ if line[ix[8]+1:ix[9]].strip():
89
+ trig_str = line[ix[8]+1:ix[9]].strip()
90
+ trig_type = trig_str[trig_str.rfind(':')+1:trig_str.rfind('/')].strip()
91
+ trig_dur = int(trig_str[trig_str.rfind('/')+1:].strip())
92
+ block.add_trig(trig_dur, trig_type, ts)
93
+
94
+ ts_old = ts
@@ -0,0 +1,148 @@
1
+ """
2
+ This code is copied from the dsvreader from Markus Boland.
3
+ """
4
+
5
+ # coding=utf-8
6
+ import numpy as np
7
+ import codecs
8
+ from collections import namedtuple
9
+ from warnings import warn
10
+
11
+ class DSVFile:
12
+ """
13
+ This class loads and uncompresses a dsv file.
14
+ """
15
+
16
+ def __decode_dsv_values(self):
17
+ """
18
+ Decodes a compressed dsv array
19
+ :return: Decompressed dsv array
20
+
21
+ The values are signed 32-bit integers. The real value
22
+ will be obtained after division by VERTFACTOR.
23
+ If the same sample value is repeated several times,
24
+ the value is only listed twice, and the third value
25
+ specifies the number of repetitions.
26
+ Only the first sample is stored as an absolute value.
27
+ All other values are deltas to the preceeding value.
28
+ (This way it is possible to compress linear slopes.)
29
+ """
30
+ ndsv = np.zeros((int(self.definitions.samples)), dtype=int)
31
+ dsv = np.array(self.values, dtype=int)
32
+ count = 0
33
+ ncount = 1
34
+ ndsv[ncount] = dsv[count]
35
+ prev = dsv[0]
36
+ while count < dsv.shape[0]-1:
37
+ count += 1
38
+ if not (dsv[count] == prev): # no repetition
39
+ ndsv[ncount] = ndsv[ncount-1] + dsv[count]
40
+ prev = dsv[count]
41
+ ncount += 1
42
+ else: # number repeated
43
+ # get repetitions
44
+ nrep = dsv[count+1]
45
+ ndsv[ncount] = ndsv[ncount-1] + dsv[count]
46
+ ncount += 1
47
+ for i in range(nrep):
48
+ ndsv[ncount] = ndsv[ncount-1] + dsv[count]
49
+ ncount += 1
50
+ # store last value
51
+ prev = dsv[count]
52
+
53
+ # inc counters
54
+ count += 1
55
+
56
+ # apply vertical factor
57
+ ndsv = ndsv / self.definitions.vertfactor
58
+ return ndsv
59
+
60
+ @staticmethod
61
+ def __isfloat(value):
62
+ """
63
+ Returns if string contains a valid float
64
+ :param value: String
65
+ :return: Float?
66
+ """
67
+ try:
68
+ float(value)
69
+ return True
70
+ except ValueError:
71
+ return False
72
+
73
+ def __init__(self, filename, encoding='cp1252'):
74
+ """
75
+ Loads the data from filename
76
+ :param filename: Path to dsv file
77
+ :param encoding: Encoding of dsv file
78
+ :return:
79
+ The values are automatically uncompressed and stored in values field. Other parameters
80
+ are stored in field given by the structure of the dsv file. The values are converted
81
+ to float if possible and remain unicode if not (should be right for all params).
82
+ """
83
+
84
+ # load file
85
+ dsv_file = codecs.open(filename, 'r', encoding)
86
+ try:
87
+ filecnt = dsv_file.readlines()
88
+ finally:
89
+ dsv_file.close()
90
+
91
+ # trim lines
92
+ filecnt = list(map(str.strip, filecnt))
93
+ filecnt = [x for x in filecnt if len(x) > 0]
94
+ filecnt = [x for x in filecnt if not x.startswith(';')]
95
+ filecnt = list(map(str.lower, filecnt))
96
+
97
+ file_data = dict()
98
+ dictkeys = {'definitions', 'filetype', 'frame'}
99
+ listkeys = {'values'}
100
+ curkey = ''
101
+ for line in filecnt:
102
+ # Section line
103
+ if line.startswith('[') and line.endswith(']'):
104
+ line = line[1:-1]
105
+ if line in dictkeys:
106
+ file_data[line] = dict()
107
+ curkey = line
108
+ elif line in listkeys:
109
+ setattr(self, line, list())
110
+ curkey = line
111
+ else:
112
+ warn(f'Unknown key {line} in DSV file {filename}... skipping.')
113
+ # Key value pair
114
+ elif line.find('=') > 0:
115
+ key, val = line.split('=')
116
+ if self.__isfloat(val):
117
+ val = float(val)
118
+ file_data[curkey][key] = val
119
+ elif curkey in listkeys:
120
+ getattr(self, curkey).append(int(line))
121
+
122
+ # set params
123
+ for _d in dictkeys:
124
+ setattr(self, _d, namedtuple(_d, list(file_data[_d].keys()))(**file_data[_d]))
125
+
126
+ # uncompress values
127
+ self.values = self.__decode_dsv_values()
128
+ self.time = np.arange(self.values.shape[0]) * self.definitions.horidelta
129
+ pass
130
+
131
+ def plot(self, smin=0, skip=1000, smax=1000000):
132
+ """
133
+ Plots the stored dsv data
134
+ :param smin: Start index to plot
135
+ :param skip: Index delta
136
+ :param smax: Stop index
137
+ :return:
138
+ """
139
+ import matplotlib.pyplot as plt
140
+
141
+ smin *= int(10./self.definitions.horidelta)
142
+ smax *= int(10./self.definitions.horidelta)
143
+ skip *= int(10./self.definitions.horidelta)
144
+
145
+ plt.plot(self.time[smin:smax:skip], self.values[smin:smax:skip])
146
+ plt.xlabel(self.definitions.horiunitname)
147
+ plt.ylabel(self.definitions.vertunitname)
148
+ pass
dsv2pulseq/sequence.py ADDED
@@ -0,0 +1,517 @@
1
+ import numpy as np
2
+ import os
3
+ import time
4
+ import copy
5
+ from warnings import warn
6
+ import logging
7
+ import pypulseq as pp
8
+ from dsv2pulseq.helper import round_up_to_raster, waveform_from_seqblock
9
+
10
+
11
+ class Block():
12
+ """
13
+ Event block in "Siemens style", allows more than one object per channel
14
+ All units are in Siemens units:
15
+ times: [us]
16
+ gradients: [mT/m]
17
+ RF pulses: [V]
18
+ """
19
+
20
+ def __init__(self, idx, duration, start_time):
21
+ self.block_idx = idx
22
+ self.block_duration = duration
23
+ self.start_time = start_time
24
+ self.timestamps = {}
25
+
26
+ def __repr__(self):
27
+ return f"Block {self.block_idx} with duration {self.block_duration} us at start time {self.start_time} us with timestamps {self.timestamps}."
28
+
29
+ def add_timestamp(self, ts):
30
+ if not ts in self.timestamps:
31
+ self.timestamps[ts] = []
32
+
33
+ def add_rf(self, duration, shape_ix, ts):
34
+ self.add_timestamp(ts)
35
+ rf = Rf(duration, shape_ix)
36
+ self.timestamps[ts].append(rf)
37
+
38
+ def add_grad(self, channel, amp, duration, ramp_up, ramp_dn, shape_ix, ts):
39
+ self.add_timestamp(ts)
40
+ grad = Grad(channel, amp, duration, ramp_up, ramp_dn, shape_ix)
41
+ self.timestamps[ts].append(grad)
42
+
43
+ def add_adc(self, duration, samples, ts):
44
+ self.add_timestamp(ts)
45
+ adc = Adc(duration, samples)
46
+ self.timestamps[ts].append(adc)
47
+
48
+ def add_trig(self, duration, trig_type, ts):
49
+ self.add_timestamp(ts)
50
+ trig = Trig(duration, trig_type)
51
+ self.timestamps[ts].append(trig)
52
+
53
+ def set_freqphase(self, freq_phase, ts):
54
+ for event in self.timestamps[ts]:
55
+ if event.type == 'rf' or event.type == 'adc':
56
+ event.set_freqphase(freq_phase)
57
+
58
+ class Rf():
59
+
60
+ def __init__(self, duration, shape_ix):
61
+ self.type = 'rf'
62
+ self.duration = duration
63
+ self.freq = 0
64
+ self.phase = 0
65
+ self.shape_ix = shape_ix
66
+
67
+ def set_freqphase(self, freq_phase):
68
+ self.freq = freq_phase[0]
69
+ self.phase = freq_phase[1]
70
+
71
+ class Grad():
72
+
73
+ def __init__(self, channel, amp, duration, ramp_up, ramp_dn, shape_ix):
74
+ self.type = 'g' + channel
75
+ self.channel = channel
76
+ self.amp = amp # amplitude in logical! coordinate system
77
+ self.duration = duration # flat_top + ramp_up
78
+ self.ramp_up = ramp_up
79
+ self.ramp_dn = ramp_dn
80
+ self.shape_ix = shape_ix
81
+
82
+ class Adc():
83
+
84
+ def __init__(self, duration, samples):
85
+ self.type = 'adc'
86
+ self.duration = duration
87
+ self.samples = samples
88
+ self.freq = 0
89
+ self.phase = 0
90
+
91
+ def set_freqphase(self, freq_phase):
92
+ self.freq = freq_phase[0]
93
+ self.phase = freq_phase[1]
94
+
95
+ class Trig():
96
+
97
+ def __init__(self, duration, trig_type):
98
+ self.type = 'trig'
99
+ self.duration = duration
100
+ self.trig_type = trig_type
101
+
102
+ class Sequence():
103
+
104
+ def __init__(self, ref_volt):
105
+ self.n_blocks = 0
106
+ self.duration = 0
107
+ self.block_list = []
108
+
109
+ self.rf = np.array([])
110
+ self.gx = np.array([])
111
+ self.gy = np.array([])
112
+ self.gz = np.array([])
113
+
114
+ # raster times
115
+ self.delta_rf = 5 # RF raster times in dsv file [us]
116
+ self.delta_grad = 10 # Gradient raster times in dsv file [us]
117
+
118
+ # Conversion factors from dsv to (Py)Pulseq (SI) units
119
+ self.gamma = 42.576e6
120
+ self.cf_time = 1e-6 # [us] -> [s]
121
+ self.cf_grad = 1e-3*self.gamma # [mT/m] -> [Hz/m]
122
+
123
+ # ref_volt is voltage for 1ms 180 deg rectangular pulse
124
+ # -> pulse [Hz] = pulse[V]/ref_volt[V] * 500[Hz]
125
+ self.cf_rf = 5e2 / ref_volt # [V] -> [Hz]
126
+
127
+ # trigger types
128
+ self.trig_types = {'EXTRIG0': 'ext1', 'OSC0': 'osc0', 'OSC1': 'osc1'}
129
+
130
+ # coil lead & hold times
131
+ self.rf_lead_time = 100 # [us]
132
+ self.rf_hold_time = 30 # [us]
133
+
134
+ # ADC dead time
135
+ self.adc_dead_time = 10 # [us]
136
+
137
+ def add_block(self, idx, duration, start_time):
138
+ self.n_blocks += 1
139
+ self.duration = start_time + duration
140
+ self.block_list.append(Block(idx, duration, start_time))
141
+
142
+ def get_block(self, idx):
143
+ return self.block_list[idx]
144
+
145
+ def set_shapes(self, shapes):
146
+
147
+ rfd = shapes[0].values * shapes[0].definitions.maxlimit / np.max(shapes[0].values)
148
+ rf = rfd * np.exp(1j*np.deg2rad(shapes[1].values))
149
+ self.rf = rf
150
+ self.gx = shapes[2].values
151
+ self.gy = shapes[3].values
152
+ self.gz = shapes[4].values
153
+ self.delta_rf = int(shapes[0].definitions.horidelta)
154
+ self.delta_grad= int(shapes[2].definitions.horidelta)
155
+
156
+ def get_shape(self, event):
157
+ """
158
+ Gets shape for RF or gradient event
159
+
160
+ Logical coordinates 'gr', 'gp', 'gs' only map to the physical coordinates
161
+ when the sequence was simulated with transversal orientation and PE direction A->P
162
+ """
163
+ if event.type == 'rf':
164
+ return self.rf[event.shape_ix]
165
+ elif event.type == 'gx' or event.type == 'gr':
166
+ return self.gx[event.shape_ix]
167
+ elif event.type == 'gy' or event.type == 'gp':
168
+ return self.gy[event.shape_ix]
169
+ elif event.type == 'gz' or event.type == 'gs':
170
+ return self.gz[event.shape_ix]
171
+ else:
172
+ return None
173
+
174
+ def set_lead_hold_time(self, rf_lead_time, rf_hold_time):
175
+ """
176
+ Set RF lead and hold times
177
+ """
178
+ self.rf_lead_time = rf_lead_time
179
+ self.rf_hold_time = rf_hold_time
180
+
181
+ def set_adc_dead_time(self, adc_dead_time):
182
+ """
183
+ Set ADC dead time
184
+ """
185
+ self.adc_dead_time = adc_dead_time
186
+
187
+ def make_pulseq_sequence(self, filename=None, fov=[None, None, None], highgain=False):
188
+ """
189
+ Create a Pulseq file from the sequence object.
190
+ Time tracking is always done in [us] (integers),
191
+ time is only converted to [s], when creating a pulseq event
192
+
193
+ Inputs:
194
+ --------
195
+ filename: If provided, Pulseq sequence will be saved to this file.
196
+ fov: Field of view in [mm] for x, y, z direction, which is set in the Pulseq file
197
+ """
198
+
199
+ logging.info("Create Pulseq sequence file.")
200
+ start_time = time.time()
201
+
202
+ filename = os.path.splitext(filename)[0] + '.seq'
203
+
204
+ system = pp.Opts(
205
+ max_grad=1e4,
206
+ max_slew=1e4,
207
+ rf_raster_time=self.delta_rf * self.cf_time,
208
+ grad_raster_time=self.delta_grad * self.cf_time,
209
+ grad_unit='mT/m',
210
+ slew_unit='mT/m/ms',
211
+ rf_ringdown_time=self.rf_hold_time * self.cf_time,
212
+ rf_dead_time=self.rf_lead_time * self.cf_time,
213
+ adc_dead_time=self.adc_dead_time * self.cf_time
214
+ )
215
+
216
+ pp_seq = pp.Sequence(system=system)
217
+ pp_seq.set_definition('Name', os.path.basename(filename))
218
+ if all(fov):
219
+ pp_seq.set_definition("FOV", [fov[0]*1e-3, fov[1]*1e-3, fov[2]*1e-3])
220
+ if highgain:
221
+ pp_seq.set_definition("ReceiverGainHigh", 1)
222
+
223
+ pulseq_events = self.__init_event_dict()
224
+ ts_offset = 0
225
+
226
+ if not self.block_list:
227
+ warn("No blocks in sequence. Please check the input DSV files. Exiting.")
228
+ return
229
+
230
+ # Shift RF and ADC timestamps to account for lead and dead times
231
+ block_list = self.make_pulseq_block_list()
232
+
233
+ for ix, block in enumerate(block_list):
234
+ if ix == 1:
235
+ pass
236
+ block_offset = block_list[ix - 1].block_duration if ix > 0 else 0
237
+ ts_offset -= block_offset # offset time if Siemens block is splitted
238
+ for ts in block.timestamps:
239
+ events = block.timestamps[ts]
240
+ concat_g = {'gx': False, 'gy': False, 'gz': False}
241
+ pulseq_time = ts - ts_offset # timestamp in the current Pulseq block [us]
242
+
243
+ # check if any event exceeds the current timestamp
244
+ event_exceeds = {}
245
+ for key, event in pulseq_events.items():
246
+ if event is not None:
247
+ duration = round(pp.calc_duration(event) / self.cf_time)
248
+ event_exceeds[key] = duration > pulseq_time
249
+ else:
250
+ event_exceeds[key] = False
251
+ exceeded = any(event_exceeds.values())
252
+
253
+ if exceeded and any(pulseq_events[event.type] is not None for event in events):
254
+ # Check if the block has to be split (Pulseq only allows one event per channel per block)
255
+ # Gradients are either splitted or concatenated, depending on the presence of ADC, RF or Trigger events
256
+ split = not (event_exceeds['rf'] or event_exceeds['adc'] or event_exceeds['trig'])
257
+ if split: # split block
258
+ split_pt = round(pulseq_time / self.delta_grad)
259
+ pulseq_events_tmp = self.__init_event_dict()
260
+ for key, pp_event in pulseq_events.items():
261
+ if pp_event is None:
262
+ continue
263
+ pp_dur = round(pp.calc_duration(pp_event) / self.cf_time)
264
+ if pp_dur > pulseq_time:
265
+ if hasattr(pp_event, 'waveform') or hasattr(pp_event, 'amplitude'):
266
+ g_pre, g_post = self.__split_gradients(pp_event, split_pt, system)
267
+ pulseq_events_tmp[key] = g_post
268
+ pulseq_events[key] = g_pre
269
+ else:
270
+ raise ValueError(f"Event with type {pp_event.type} in block with index {block.block_idx} "
271
+ "cannot be split. Only gradients can be split.")
272
+ if any(pulseq_events.values()):
273
+ pp_seq.add_block(*self.__extract_events(pulseq_events))
274
+ pulseq_events = pulseq_events_tmp.copy()
275
+ ts_offset = round(ts / self.delta_grad) * self.delta_grad
276
+ else: # concatenate gradients
277
+ for event in events:
278
+ if pulseq_events[event.type] is not None:
279
+ pp_event = pulseq_events[event.type]
280
+ if event.type[0] == 'g':
281
+ g_conc = self.__make_pp_grad(event, pulseq_time, system)
282
+ if g_conc is not None:
283
+ g_wf = [waveform_from_seqblock(pp_event, system=system), waveform_from_seqblock(g_conc, system=system)]
284
+ len_wf = max([len(wf) for wf in g_wf])
285
+ g_wf = [np.concatenate([wf, np.zeros(len_wf - len(wf))]) for wf in g_wf]
286
+ g_wf = g_wf[0] + g_wf[1]
287
+ pulseq_events[event.type] = self.__make_arbitrary_grad(g_wf, event.channel, 0, system=system)
288
+ concat_g[event.type] = True
289
+ else:
290
+ raise ValueError(f"Event with type {pp_event.type} in block with index {block.block_idx} "
291
+ "cannot be concatenated. Only gradients can be concatenated.")
292
+ elif not exceeded:
293
+ # no split or concatenation needed, start new block
294
+ block_dur = pp.calc_duration(*self.__extract_events(pulseq_events))
295
+ block_dur_rounded = round_up_to_raster(pp.calc_duration(*self.__extract_events(pulseq_events)), 5)
296
+ if abs(block_dur - block_dur_rounded) > 1e-7:
297
+ pulseq_events['delay'] = pp.make_delay(block_dur_rounded)
298
+ if any(pulseq_events.values()):
299
+ pp_seq.add_block(*self.__extract_events(pulseq_events))
300
+
301
+ # Add remaining delay to the new block
302
+ delay = round((ts - ts_offset) * self.cf_time, 5)
303
+ delay_remain = delay - block_dur_rounded
304
+ pulseq_events = self.__init_event_dict()
305
+ if delay_remain > 0:
306
+ pulseq_events['delay'] = pp.make_delay(delay_remain)
307
+ ts_offset += round(block_dur_rounded / self.cf_time)
308
+
309
+ # add events to the block
310
+ for event in events:
311
+ event_del = ts - ts_offset
312
+ if event_del < 0:
313
+ raise ValueError("Negative event delay encountered.")
314
+ if event.type == 'rf':
315
+ event_del += self.rf_lead_time
316
+ rf = self.__make_pp_rf(event, event_del, system)
317
+ pulseq_events['rf'] = rf
318
+ elif event.type in ['gx', 'gy', 'gz']:
319
+ if not concat_g[event.type]:
320
+ g = self.__make_pp_grad(event, event_del, system)
321
+ if g is not None:
322
+ pulseq_events[event.type] = g
323
+ elif event.type == 'adc':
324
+ event_del += self.adc_dead_time
325
+ adc_dur = round(event.duration * self.cf_time, 7) # ADCs can be on nanosecond raster
326
+ adc_del = round(event_del * self.cf_time, 6)
327
+ adc = pp.make_adc(num_samples=event.samples, duration=adc_dur, delay=adc_del, freq_offset=event.freq, phase_offset=np.deg2rad(event.phase), system=system)
328
+ pulseq_events['adc'] = adc
329
+ elif event.type == 'trig':
330
+ trig_dur = round(event.duration * self.cf_time, 6)
331
+ trig_del = round(event_del * self.cf_time, 6)
332
+ if event.trig_type in self.trig_types:
333
+ trig = pp.make_digital_output_pulse(channel=self.trig_types[event.trig_type], duration=trig_dur, delay=trig_del, system=system)
334
+ pulseq_events['trig'] = trig
335
+ else:
336
+ delay = trig_dur + trig_del
337
+ if self.__check_delay(pulseq_events, delay):
338
+ pulseq_events['trig'] = pp.make_delay(d=delay)
339
+ logging.info(f'Unknown trigger type {event.trig_type} in block {block.block_idx} at time stamp {ts}. Replaced with delay.')
340
+
341
+ # add last block
342
+ delay = round((ts - ts_offset) * self.cf_time, 5)
343
+ if self.__check_delay(pulseq_events, delay):
344
+ pulseq_events['delay'] = pp.make_delay(delay) # account for possible delay in last block
345
+ if any(pulseq_events.values()):
346
+ pp_seq.add_block(*self.__extract_events(pulseq_events))
347
+
348
+ # check timing of the sequence
349
+ # set raster time to 1us for timing check, as otherwise ADC delays will throw errors
350
+ pp_seq.system.rf_raster_time = 1e-6
351
+ ok, error_report = pp_seq.check_timing()
352
+ pp_seq.system.rf_raster_time = self.delta_rf * self.cf_time
353
+ if not ok:
354
+ warn(f"PyPulseq timing check failed with {len(error_report)} errors.")
355
+
356
+ # write sequence
357
+ pp_seq.write(filename, check_timing=False)
358
+ end_time = time.time()
359
+ logging.info(f"Finished creating Pulseq file in {(end_time - start_time):.2f}s.")
360
+
361
+ return pp_seq
362
+
363
+ def __init_event_dict(self):
364
+ return {'rf': None, 'gx': None, 'gy': None, 'gz': None, 'adc': None, 'trig': None, 'delay': None}
365
+
366
+ def __extract_events(self, event_dict):
367
+ return [event for event in event_dict.values() if event is not None]
368
+
369
+ def __make_pp_rf(self, rf_event, event_del, system):
370
+ """
371
+ Make a Pulseq RF event
372
+ """
373
+
374
+ rf_sig = self.get_shape(rf_event)
375
+ rf_del = round(event_del*self.cf_time, 6)
376
+ rf = pp.make_arbitrary_rf(signal=rf_sig, flip_angle=1, delay=rf_del, freq_offset=rf_event.freq, phase_offset=np.deg2rad(rf_event.phase), return_gz=False, system=system)
377
+ rf.signal = rf_sig * self.cf_rf # reset the signal as it gets scaled in make_arbitrary_rf
378
+ return rf
379
+
380
+ def __make_pp_grad(self, grad_event, event_del, system):
381
+ """
382
+ Make a Pulseq gradient event
383
+ """
384
+
385
+ if grad_event.duration == 0 and grad_event.ramp_up == 0:
386
+ # zero duration gradient
387
+ return None
388
+ else:
389
+ g_wf = self.get_shape(grad_event) * self.cf_grad
390
+ g_del = round(event_del*self.cf_time, 5)
391
+ return self.__make_arbitrary_grad(g_wf, grad_event.channel, delay=g_del, system=system)
392
+
393
+ def __make_arbitrary_grad(self, wf, channel, delay, system):
394
+
395
+ if len(wf) > 1:
396
+ grad = pp.make_arbitrary_grad(channel=channel, waveform=wf, delay=delay, system=system)
397
+ else:
398
+ # Pypulseq throws an error if the waveform has only one point.
399
+ grad = pp.make_arbitrary_grad(
400
+ channel=channel,
401
+ waveform=np.concatenate([wf, np.zeros(1, dtype=float)]),
402
+ delay=0,
403
+ system=system
404
+ )
405
+ grad.waveform = wf
406
+ grad.tt = (np.arange(len(wf)) + 0.5) * system.grad_raster_time
407
+ grad.shape_dur = len(wf) * system.grad_raster_time
408
+ grad.first = wf[0]
409
+ grad.last = wf[0]
410
+ grad.area = np.sum(wf * system.grad_raster_time)
411
+ return grad
412
+
413
+ def __split_gradients(self, grad_event, split_pt, system):
414
+ """
415
+ Split gradients at the split point.
416
+ """
417
+
418
+ g_wf = waveform_from_seqblock(grad_event, system=system) # already contains delay
419
+ g_pre_wf = g_wf[:split_pt]
420
+ g_post_wf = g_wf[split_pt:]
421
+
422
+ g_pre = self.__make_arbitrary_grad(g_pre_wf, grad_event.channel, 0, system)
423
+ g_post = self.__make_arbitrary_grad(g_post_wf, grad_event.channel, 0, system)
424
+ return g_pre, g_post
425
+
426
+ def __check_delay(self, pulseq_events, delay):
427
+ """
428
+ Check if delay is the longest delay in the current event block
429
+ """
430
+ pulseq_events = self.__extract_events(pulseq_events)
431
+ delays = [item.delay for item in pulseq_events if (item.type == "delay" and item is not None)] + [0]
432
+ if delay > max(delays):
433
+ return True
434
+ else:
435
+ return False
436
+
437
+ def make_pulseq_block_list(self):
438
+ """
439
+ Restructures the block list to simplify writing the Pulseq file. This includes:
440
+ - RF events are shifted by the RF lead time to not violate it
441
+ - ADC events are shifted by the ADC dead time to not violate it
442
+
443
+ Additionally, gradient events are created on all axes for all time periods where a gradient is present on any axis.
444
+ Gradient timing from the INF file is in logical coordinates, while gradient waveforms (GRX/GRY/GRZ) are in physical coordinates.
445
+ Since the rotation matrix is unavailable, we conservatively assume all axes are active wherever any axis has gradient activity.
446
+
447
+ Returns:
448
+ --------
449
+ A copy of self.block_list with 'rf' events shifted earlier by self.rf_lead_time.
450
+ The original self.block_list remains unchanged.
451
+ """
452
+
453
+ # Deep copy to avoid modifying original blocks or event lists
454
+ block_list_shifted = copy.deepcopy(self.block_list)
455
+
456
+ grad_offset = {'x': 0, 'y': 0, 'z': 0}
457
+ for block in block_list_shifted:
458
+ shifted_timestamps = {}
459
+ grad_ts = {'x': 0, 'y': 0, 'z': 0}
460
+ grad_end_last = {'x': 0, 'y': 0, 'z': 0}
461
+ grads = {'x': None, 'y': None, 'z': None}
462
+
463
+ for ts_str in block.timestamps:
464
+ ts = int(ts_str)
465
+ events = block.timestamps[ts_str]
466
+
467
+ if not events:
468
+ shifted_timestamps.setdefault(ts, [])
469
+
470
+ for event in events:
471
+ if event.type == 'rf':
472
+ new_ts = ts - self.rf_lead_time
473
+ shifted_timestamps.setdefault(new_ts, []).append(event)
474
+
475
+ elif event.type == 'adc':
476
+ new_ts = ts - self.adc_dead_time
477
+ shifted_timestamps.setdefault(new_ts, []).append(event)
478
+
479
+ elif event.type[0] == 'g':
480
+ for axis, g in grads.items():
481
+ grad_start = max(ts, grad_offset[axis])
482
+ grad_dur = event.duration + event.ramp_dn
483
+ grad_end = ts + grad_dur
484
+ grad_dur = grad_end - grad_start # correct if offset > start
485
+ shape_ix = slice((block.start_time + grad_start) // self.delta_grad,
486
+ (block.start_time + grad_start + grad_dur) // self.delta_grad)
487
+
488
+ if grad_start >= grad_end_last[axis] and grad_end > grad_offset[axis]:
489
+ if g is not None:
490
+ shifted_timestamps.setdefault(grad_ts[axis], []).append(g)
491
+
492
+ g = Grad(axis, 0, grad_dur, grad_dur, 0, shape_ix)
493
+ if any(self.get_shape(g)):
494
+ grads[axis] = g
495
+ grad_ts[axis] = grad_start
496
+ grad_end_last[axis] = grad_end
497
+
498
+ elif grad_end > grad_end_last[axis]:
499
+ grad_dur_new = g.duration + grad_end - grad_end_last[axis]
500
+ shape_end_new = g.shape_ix.stop + (grad_end - grad_end_last[axis]) // self.delta_grad
501
+ g.duration = g.ramp_up = grad_dur_new
502
+ g.shape_ix = slice(g.shape_ix.start, shape_end_new)
503
+ grad_end_last[axis] = grad_end
504
+
505
+ else:
506
+ shifted_timestamps.setdefault(ts, []).append(event)
507
+
508
+ for axis, g in grads.items():
509
+ grad_offset[axis] = grad_end_last[axis] - block.block_duration # gradients can extend beyond the block duration
510
+ if g is not None:
511
+ shifted_timestamps.setdefault(grad_ts[axis], []).append(g)
512
+
513
+ block.timestamps = dict(
514
+ sorted(((ts, evts) for ts, evts in shifted_timestamps.items()), key=lambda x: int(x[0]))
515
+ )
516
+
517
+ return block_list_shifted
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: dsv2pulseq
3
+ Version: 0.0.0
4
+ Summary: Create Pulseq sequences from dsv.
5
+ Author-email: Marten Veldmann <marten.veldmann@dzne.de>
6
+ License: MIT License
7
+ Project-URL: Homepage, https://github.com/mavel101/dsv2pulseq
8
+ Keywords: pulseq,dsv,siemens,mri
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: numpy
13
+ Requires-Dist: matplotlib
14
+ Requires-Dist: pypulseq==1.4.2.post1
15
+ Requires-Dist: ismrmrd==1.14.1
16
+ Requires-Dist: twixtools==0.23
17
+ Dynamic: license-file
18
+
19
+ # dsv2pulseq
20
+
21
+ Create Pulseq sequence files from Siemens dsv simulation files. The converter was only tested with VE/VX dsv file layouts.
22
+ VB/VD files are not supported, as the simulator can not output RF phase data.
23
+ RF values might be differing slightly compared to the original sequence, as the dsv files contain RF values only on a 5us raster.
24
+
25
+ ## Dependencies & Installation
26
+
27
+ A Python environment with the package installed can be created with the provided yml file: `conda env create -f dsv2pulseq.yml`.
28
+ Unittests can be run with `python -m unittest discover test/`. This includes testing, whether the conversion is successful for each dsv dataset in the folder "test/test_data".
29
+
30
+ This package only depends on numpy and PyPulseq [1].
31
+
32
+ Merging data to the original Siemens raw data file for retrospective reconstruction (see "Reconstruction of Pulseq data") requires the twixtools package (https://github.com/pehses/twixtools).
33
+
34
+ ## Sequence simulation
35
+
36
+ Simulate the sequence with the following settings:
37
+ - Transversal orientation with phase-encode direction A->P and no FOV shift (which is the default)
38
+ - Simulate with RF phase output (sim /RFP+).
39
+ - Mandatory dsv files are "_INF", "_RFD", "_RFP", "_GRX", "_GRY" and "_GRZ".
40
+
41
+ Example data "MiniFLASH.dsv" can be found in the test/test_data folder. These simulation files are from the Siemens MiniFLASH demo sequence.
42
+
43
+ ## Create Pulseq output
44
+
45
+ The conversion can be started by running:
46
+ ```
47
+ dsv_to_pulseq -o OUT_FILE -r REF_VOLT IN_FILE_PREFIX
48
+ ```
49
+ from the shell.
50
+
51
+ The IN_FILE_PREFIX is the prefix of the dsv files, e.g. "gre" for "gre_XXX.dsv". The OUT_FILE is the Pulseq output sequence file (default: "external.seq"). The reference voltage is the voltage the sequence was simulated with (Siemens default: 223.529007 V).
52
+
53
+ The conversion can also be done in Python by running:
54
+ ```
55
+ from dsv2pulseq import read_dsv
56
+ seq = read_dsv('/path/to/dsv/dsv_prefix')
57
+ seq_pulseq = seq.make_pulseq_sequence('external.seq')
58
+ ```
59
+
60
+ There is an experimental function to check the shapes of RF waveforms and gradients that plots the difference between the original and converted waveforms:
61
+ ```
62
+ from dsv2pulseq import check_dsv
63
+ check_dsv('/path_to_dsv/dsv_prefix_original', 'path_to_dsv/dsv_prefix_pulseq')
64
+ ```
65
+
66
+ Note that:
67
+ - Only single transmit channel RF pulses are currently supported.
68
+ - The Pulseq sequence has the same orientation as the original sequence in the physical/scanner coordinate system, when running the Pulseq interpreter in "XYZ in TRA" mode.
69
+ - The Pulseq interpreter version 1.5.0 should be used, as the versions before contain a bug in setting the RF raster time correctly.
70
+ - RF and gradient waveforms might be slightly different compared to the original sequence due to limited numerical accuracy of the DSV files.
71
+ - RF pulse values are on a 5us raster in the dsv files, which might be insufficient for pulses with rapidly varying phase
72
+
73
+ ## Reconstruction of Pulseq data
74
+
75
+ Data acquired with the converted Pulseq sequence cannot be easily reconstructed, as the scan headers containing the meta information are missing.
76
+ The data from the Pulseq sequence (IN_FILE_2) can be inserted into a raw data file acquired with the original sequence (IN_FILE_1) using twixtools (see dependencies). If twixtools is installed, the following command will insert the data:
77
+ ```
78
+ insert_twix_data IN_FILE_1 IN_FILE_2 -o OUT_FILE
79
+ ```
80
+ The merged output file (OUT_FILE) can be used for rectrospective reconstruction at the scanner. Alternatively the Pulseq data file can also be in the MRD format (requires ISMRMRD python package)
81
+
82
+ ## References
83
+
84
+ [1] Ravi, Keerthi, Sairam Geethanath, and John Vaughan. "PyPulseq: A Python Package for MRI Pulse Sequence Design." Journal of Open Source Software 4.42 (2019): 1725., https://github.com/imr-framework/pypulseq
@@ -0,0 +1,14 @@
1
+ dsv2pulseq/__init__.py,sha256=owW-fIyzHOvugm4hJwBXLvSLfjjCKJ76obKteZsXZCU,41
2
+ dsv2pulseq/dsv_to_pulseq.py,sha256=N_CKKwWOBKst8aQ9cFdMIHu4gLIXv4TQJko17BGmR9Y,1867
3
+ dsv2pulseq/helper.py,sha256=irbn8beTvuSnSVBXP0r3Bs1trOg4BhPYQ8L4KXkLoaw,1633
4
+ dsv2pulseq/insert_twix_data.py,sha256=ap6NwrdO9g1_ZyyJ-eIUyesFIEkd_TkTsWCQYGUnIMo,2394
5
+ dsv2pulseq/read_dsv.py,sha256=6YgAk7Ex887uyLMCHaIcDcx3n6aeOQcQqg4FATLcWsc,6216
6
+ dsv2pulseq/read_dsv_inf.py,sha256=9InZBXvRsBwZLKO6gmCZ9FAAaXLlGESdYuLckMpt2p8,4959
7
+ dsv2pulseq/read_dsv_samples.py,sha256=OSVwsFNv37JBAAwJhg3Z2SXVRCB7xaBUo3vQlVqTTF8,4950
8
+ dsv2pulseq/sequence.py,sha256=ogVnsfMgeC07rghViObZgY3Tz979VOnxC-o97Tn3SgI,22793
9
+ dsv2pulseq-0.0.0.dist-info/licenses/LICENSE,sha256=BLTe_Dlta_WTwEeHsZVasjaklHAx6IFpWguMWroxs4U,1072
10
+ dsv2pulseq-0.0.0.dist-info/METADATA,sha256=qZf-DziA4OSRfHxTo3SUdMlGm48o-vKB2MKz98H7lQw,4320
11
+ dsv2pulseq-0.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ dsv2pulseq-0.0.0.dist-info/entry_points.txt,sha256=9r2VipfrgHXLDTwsGeyCK7XyJ0lcdycCDMJO4lVc4Bo,116
13
+ dsv2pulseq-0.0.0.dist-info/top_level.txt,sha256=HE9Nz1e1uYO0SPUX-Ub868nHguGulocRXGUgAEi3S4g,11
14
+ dsv2pulseq-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dsv_to_pulseq = dsv2pulseq.dsv_to_pulseq:main
3
+ insert_twix_data = dsv2pulseq.insert_twix_data:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Marten Veldmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ dsv2pulseq