seq2xml.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. # seq2xml.py : converts Pulseq (.seq) files into JEMRIS (.xml) sequences
  2. # Gehua Tong
  3. # March 2020
  4. from MRI_sequences.pypulseq.Sequence.sequence import Sequence
  5. from MRI_sequences.pypulseq.calc_duration import calc_duration
  6. import xml.etree.ElementTree as ET
  7. import h5py
  8. import numpy as np
  9. from math import pi
  10. # Notes
  11. # This is for generating an .xml file for input into JEMRIS simulator, from a Pulseq .seq file
  12. # The opposite philosophies make the .xml encoding suboptimal for storage
  13. # (because seq files consists of flattened-out Blocks while the JEMRIS format minimizes repetition using loops
  14. # and consists of many cross-referencing of parameters)
  15. # Consider: for virtual scanner, have scripts that generate .xml and .seq at the same time (looped vs. flattened)
  16. # (but isn't JEMRIS already doing that? JEMRIS does have an "output to pulseq" functionality)
  17. # though then, having a Python interface instead of a MATLAB one is helpful in the open-source aspect
  18. # Unit conversion constants (comment with units before & after)
  19. rf_const = 2 * pi / 1000 # from Pulseq[Hz]=[1/s] to JEMRIS[rad/ms] rf magnitude conversion constant
  20. g_const = 2 * pi / 1e6 # from Pulseq [Hz/m] to JEMRIS [(rad/ms)/mm] gradient conversion constant
  21. slew_const = g_const / 1000 # from Pulseq [Hz/(m*s)] to JEMRIS [(rad/ms)/(mm*ms)]
  22. ga_const = 2 * pi / 1000 # from Pulseq[1/m] to JEMRIS [2*pi/mm] gradient area conversion constant
  23. sec2ms = 1000 # time conversion constant
  24. rad2deg = 180/pi
  25. freq_const = 2 * pi / 1000 # From Hz to rad/ms
  26. def seq2xml(seq, seq_name, out_folder):
  27. """
  28. # Takes a Pulseq sequence and converts it into .xml format for JEMRIS
  29. # All RF and gradient shapes are stored as .h5 files
  30. Inputs
  31. ------
  32. seq : pypulseq.Sequence.sequence.Sequence
  33. seq_name : name of output .xml file
  34. out_folder : str
  35. Path to output folder for .xml file
  36. Returns
  37. -------
  38. seq_tree : xml.etree.ElementTree
  39. Tree object used for generating the sequence .xml file
  40. seq_path : str
  41. Path to stored .xml sequence
  42. """
  43. # Parameters is the root of the xml
  44. root = ET.Element("Parameters")
  45. # Add gradient limits (seem to be the only parameters shared by both formats)
  46. # TODO check units
  47. root.set("GradMaxAmpl", str(seq.system.max_grad*g_const))
  48. root.set("GradSlewRate", str(seq.system.max_slew*slew_const))
  49. # ConcatSequence is the element for the sequence itself;
  50. # Allows addition of multiple AtomicSequence
  51. C0 = ET.SubElement(root, "ConcatSequence")
  52. # Use helper functions to save all RF and only arbitrary gradient info
  53. rf_shapes_path_dict = save_rf_library_info(seq, out_folder)
  54. grad_shapes_path_dict = save_grad_library_info(seq, out_folder)
  55. #print(grad_shapes_path_dict)
  56. #///////////////////////////////////////////////////////////////////////////
  57. rf_name_ind = 1
  58. grad_name_ind = 1
  59. delay_name_ind = 1
  60. adc_name_ind = 1
  61. ##### Main loop! #####
  62. # Go through all blocks and add in events; each block is one AtomicSequence
  63. for block_ind in range(1,len(seq.block_events)+1):
  64. blk = seq.get_block(block_ind).__dict__
  65. exists_adc = 'adc' in blk.keys()
  66. adc_already_added = False
  67. # Note: "EmptyPulse" class seems to allow variably spaced ADC sampling
  68. # Distinguish between delay and others
  69. # Question: in pulseq, does delay happen together with other events?
  70. # (for now, we assume delay always happens by itself)
  71. # About name of atomic sequences: not adding names for now;
  72. # (Likely it will cause no problems because there is no cross-referencing)
  73. C_block = ET.SubElement(C0, "ATOMICSEQUENCE")
  74. C_block.set("Name", f'C{block_ind}')
  75. for key in blk.keys():
  76. # Case of RF pulse
  77. if key == 'rf':
  78. rf = blk['rf']
  79. if not (rf is None):
  80. rf_atom = ET.SubElement(C_block, "EXTERNALRFPULSE")
  81. rf_atom.set("Name", f'R{rf_name_ind}')
  82. rf_name_ind += 1
  83. rf_atom.set("InitialDelay", str(rf.delay*sec2ms))
  84. rf_atom.set("InitialPhase", str(rf.phase_offset*rad2deg))
  85. rf_atom.set("Frequency", str(rf.freq_offset*freq_const))
  86. # Find ID of this rf event
  87. rf_id = seq.block_events[block_ind][1]
  88. rf_atom.set("Filename", rf_shapes_path_dict[rf_id])
  89. rf_atom.set("Scale","1")
  90. rf_atom.set("Interpolate", "0") # Do interpolate
  91. gnames_map = {'gx':2, 'gy':3, 'gz':4}
  92. if key in ['gx', 'gy', 'gz']:
  93. g = blk[key]
  94. if not (g is None):
  95. if g.type == "trap":
  96. if g.amplitude != 0:
  97. g_atom = ET.SubElement(C_block, "TRAPGRADPULSE")
  98. g_atom.set("Name", f'G{grad_name_ind}')
  99. grad_name_ind += 1
  100. if g.flat_time > 0:
  101. # 1. Case where flat_time is nonzero
  102. # Second, fix FlatTopArea and FlatTopTime
  103. g_atom.set("FlatTopArea", str(g.flat_area*ga_const))
  104. g_atom.set("FlatTopTime", str(g.flat_time*sec2ms))
  105. # Last, set axis and delay
  106. else:
  107. # 2. Case of triangular pulse (i.e. no flat part)
  108. g_atom.set("MaxAmpl", str(np.absolute(g.amplitude*g_const))) # limit amplitude
  109. g_atom.set("Area", str(0.5*(g.rise_time + g.fall_time)*g.amplitude*ga_const))
  110. # Third, limit duration by limiting slew rate
  111. g_atom.set("SlewRate", str((g.amplitude * g_const) / (g.fall_time * sec2ms)))
  112. g_atom.set("Asymmetric", str(g.fall_time / g.rise_time))
  113. g_atom.set("Axis", key.upper())
  114. g_atom.set("InitialDelay", str(g.delay*sec2ms))
  115. # Add ADC if it exists and then "mark as complete"
  116. elif g.type == "grad":
  117. # Set arbitrary grad parameters
  118. # Need to load h5 file again, just like in RF
  119. g_id = seq.block_events[block_ind][gnames_map[key]]
  120. g_atom = ET.SubElement(C_block, "EXTERNALGRADPULSE")
  121. g_atom.set("Name", f'G{grad_name_ind}')
  122. grad_name_ind += 1
  123. g_atom.set("Axis", key.upper())
  124. g_atom.set("Filename", grad_shapes_path_dict[g_id])
  125. g_atom.set("Scale","1")
  126. g_atom.set("Interpolate","0")
  127. g_atom.set("InitialDelay",str(g.delay*sec2ms))
  128. else:
  129. print(f'Gradient type "{g.type}" indicated')
  130. raise ValueError("Gradient's type should be either trap or grad")
  131. if exists_adc and not adc_already_added:
  132. adc = blk['adc']
  133. if not (adc is None):
  134. dwell = adc.dwell*sec2ms
  135. adc_delay = adc.delay*sec2ms
  136. Nro = adc.num_samples
  137. gzero_adc = ET.SubElement(C_block, "TRAPGRADPULSE")
  138. gzero_adc.set("Name", f'S{adc_name_ind}')
  139. adc_name_ind += 1
  140. gzero_adc.set("ADCs", str(Nro))
  141. gzero_adc.set("FlatTopTime", str(dwell*Nro))
  142. gzero_adc.set("FlatTopArea","0")
  143. gzero_adc.set("InitialDelay", str(adc_delay))
  144. adc_already_added = True
  145. # Now, it always attach ADC to the first gradient found among keys()
  146. # This might be tricky
  147. # suggestion 1: check the duration of gradient?
  148. # suggestion 2: just do any gradient and hope it works
  149. # suggestion 3: read the JEMRIS documentation/try on GUI
  150. if key == 'delay':
  151. delay_dur = blk['delay'].delay
  152. delay_atom = ET.SubElement(C0, "DELAYATOMICSEQUENCE")
  153. delay_atom.set("Name",f'D{delay_name_ind}')
  154. delay_name_ind += 1
  155. delay_atom.set("Delay",str(delay_dur*sec2ms))
  156. delay_atom.set("DelayType","B2E")
  157. # Output it!
  158. seq_tree = ET.ElementTree(root)
  159. seq_path = out_folder + '/' + seq_name + '.xml'
  160. seq_tree.write(seq_path)
  161. return seq_tree, seq_path
  162. def save_rf_library_info(seq, out_folder):
  163. """
  164. Helper function that stores distinct RF waveforms for seq2xml
  165. """
  166. # RF library
  167. rf_shapes_path_dict = {}
  168. for rf_id in list(seq.rf_library.data.keys()): # for each RF ID
  169. # JEMRIS wants:
  170. # "Filename": A HDF5-file with a single dataset "extpulse"
  171. # of size N x 3 where the 1st column holds the time points,
  172. # and 2nd and 3rd column hold amplitudes and phases, respectively.
  173. # Phase units should be radians.
  174. # Time is assumed to increase and start at zero.
  175. # The last time point defines the length of the pulse.
  176. file_path_partial = f'rf_{int(rf_id)}.h5'
  177. file_path_full = out_folder + '/' + file_path_partial
  178. # De-compress using inbuilt PyPulseq method
  179. rf = seq.rf_from_lib_data(seq.rf_library.data[rf_id])
  180. # Only extract time, magnitude, and phase
  181. # We leave initial phase and freq offset to the main conversion loop)
  182. times = rf.t
  183. magnitude = np.absolute(rf.signal)
  184. phase = np.angle(rf.signal)
  185. N = len(magnitude)
  186. # Create file
  187. f = h5py.File(file_path_full, 'a')
  188. if "extpulse" in f.keys():
  189. del f["extpulse"]
  190. #f.create_dataset("extpulse", (N,3), dtype='f')
  191. f.create_dataset("extpulse",(3,N),dtype='f')
  192. times = times - times[0]
  193. f["extpulse"][0,:] = times*sec2ms#*sec2ms
  194. f["extpulse"][1,:] = magnitude*rf_const
  195. f["extpulse"][2,:] = phase#"Phase should be radians"
  196. f.close()
  197. rf_shapes_path_dict[rf_id] = file_path_partial
  198. return rf_shapes_path_dict
  199. # Helper function
  200. def save_grad_library_info(seq, out_folder):
  201. """
  202. Helper function that stores distinct gradients for seq2xml
  203. """
  204. #file_paths = [out_folder + f'grad_{int(grad_id)}.h5' for grad_id in range(1,N_grad_id+1)]
  205. grad_shapes_path_dict = {}
  206. processed_g_inds = []
  207. for nb in range(1,len(seq.block_events)+1):
  208. gx_ind, gy_ind, gz_ind = seq.block_events[nb][2:5]
  209. for axis_ind, g_ind in enumerate([gx_ind, gy_ind, gz_ind]):
  210. # Only save a gradient file if ...(a) it has non-zero index
  211. # (b) it is type 'grad', not 'trap'
  212. # s and (c) its index has not been processed
  213. if g_ind != 0 and len(seq.grad_library.data[g_ind]) == 3 \
  214. and g_ind not in processed_g_inds:
  215. print(f'Adding Gradient Number {g_ind}')
  216. this_block = seq.get_block(nb)
  217. file_path_partial = f'grad_{int(g_ind)}.h5'
  218. file_path_full = out_folder + '/' + file_path_partial
  219. #TODO make it work for x/y/z
  220. if axis_ind == 0:
  221. t_points = this_block.gx.t
  222. g_shape = this_block.gx.waveform
  223. elif axis_ind == 1:
  224. t_points = this_block.gy.t
  225. g_shape = this_block.gy.waveform
  226. elif axis_ind == 2:
  227. t_points = this_block.gz.t
  228. g_shape = this_block.gz.waveform
  229. N = len(t_points)
  230. # Create file
  231. f = h5py.File(file_path_full, 'a')
  232. if "extpulse" in f.keys():
  233. del f["extpulse"]
  234. f.create_dataset("extpulse", (2,N), dtype='f')
  235. f["extpulse"][0,:] = t_points * sec2ms
  236. f["extpulse"][1,:] = g_shape * g_const
  237. f.close()
  238. grad_shapes_path_dict[g_ind] = file_path_partial
  239. processed_g_inds.append(g_ind)
  240. return grad_shapes_path_dict
  241. if __name__ == '__main__':
  242. print('')
  243. seq = Sequence()
  244. seq.read('sim/test0504/gre32.seq')
  245. seq2xml(seq, seq_name='gre32_twice', out_folder='sim/test0504')
  246. # seq.read('seq_files/spgr_gspoil_N16_Ns1_TE5ms_TR10ms_FA30deg.seq')
  247. #seq.read('benchmark_seq2xml/gre_jemris.seq')
  248. # seq.read('try_seq2xml/spgr_gspoil_N15_Ns1_TE5ms_TR10ms_FA30deg.seq')
  249. #seq.read('orc_test/seq_2020-02-26_ORC_54_9_384_1.seq')
  250. #stree = seq2xml(seq, seq_name="ORC-Marina", out_folder='orc_test')