safe_pns_prediction.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. # This code is a direct Python translation of the relevant functions in
  2. # https://github.com/filip-szczepankiewicz/safe_pns_prediction/ to perform
  3. # PNS calculations with pypulseq
  4. #
  5. # A small modification was made to safe_plot to plot long sequences better
  6. # BSD 3-Clause License
  7. # Copyright (c) 2018, Filip Szczepankiewicz and Thomas Witzel
  8. # All rights reserved.
  9. # Redistribution and use in source and binary forms, with or without
  10. # modification, are permitted provided that the following conditions are met:
  11. # 1. Redistributions of source code must retain the above copyright notice, this
  12. # list of conditions and the following disclaimer.
  13. # 2. Redistributions in binary form must reproduce the above copyright notice,
  14. # this list of conditions and the following disclaimer in the documentation
  15. # and/or other materials provided with the distribution.
  16. # 3. Neither the name of the copyright holder nor the names of its
  17. # contributors may be used to endorse or promote products derived from
  18. # this software without specific prior written permission.
  19. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  20. # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  21. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  22. # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
  23. # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  24. # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
  25. # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
  26. # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
  27. # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  28. # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  29. from types import SimpleNamespace
  30. import numpy as np
  31. import matplotlib.pyplot as plt
  32. def safe_example_hw():
  33. # function hw = safe_example_hw()
  34. #
  35. # SAFE model parameters for EXAMPLE scanner hardware (not a real scanner).
  36. # See comments for units.
  37. hw = SimpleNamespace()
  38. hw.name = 'MP_GPA_EXAMPLE'
  39. hw.checksum = '1234567890'
  40. hw.dependency = ''
  41. hw.x = SimpleNamespace()
  42. hw.x.tau1 = 0.20 # ms
  43. hw.x.tau2 = 0.03 # ms
  44. hw.x.tau3 = 3.00 # ms
  45. hw.x.a1 = 0.40
  46. hw.x.a2 = 0.10
  47. hw.x.a3 = 0.50
  48. hw.x.stim_limit = 30.0 # T/m/s
  49. hw.x.stim_thresh = 24.0 # T/m/s
  50. hw.x.g_scale = 0.35 # 1
  51. hw.y = SimpleNamespace()
  52. hw.y.tau1 = 1.50 # ms
  53. hw.y.tau2 = 2.50 # ms
  54. hw.y.tau3 = 0.15 # ms
  55. hw.y.a1 = 0.55
  56. hw.y.a2 = 0.15
  57. hw.y.a3 = 0.30
  58. hw.y.stim_limit = 15.0 # T/m/s
  59. hw.y.stim_thresh = 12.0 # T/m/s
  60. hw.y.g_scale = 0.31 # 1
  61. hw.z = SimpleNamespace()
  62. hw.z.tau1 = 2.00 # ms
  63. hw.z.tau2 = 0.12 # ms
  64. hw.z.tau3 = 1.00 # ms
  65. hw.z.a1 = 0.42
  66. hw.z.a2 = 0.40
  67. hw.z.a3 = 0.18
  68. hw.z.stim_limit = 25.0 # T/m/s
  69. hw.z.stim_thresh = 20.0 # T/m/s
  70. hw.z.g_scale = 0.25 # 1
  71. return hw
  72. def safe_example_gwf():
  73. # function function [gwf, rf, dt] = safe_example_gwf()
  74. # Waveform with some frequency matching by Filip Szczepankiewicz.
  75. #
  76. # Waveform was optimized in the NOW framework by Jens Sjölund et al.
  77. # https://github.com/jsjol/NOW
  78. #
  79. # Optimization was Maxwell-compensated to remove effects of concomitant
  80. # gradients.
  81. # https://arxiv.org/ftp/arxiv/papers/1903/1903.03357.pdf
  82. ## STE
  83. dt = 1e-3 # ms
  84. # T/m
  85. gwf = 0.08 * np.array([
  86. [0, 0, 0],
  87. [-0.2005, 0.9334, 0.3029],
  88. [-0.2050, 0.9324, 0.3031],
  89. [-0.2146, 0.9302, 0.3032],
  90. [-0.2313, 0.9263, 0.3030],
  91. [-0.2589, 0.9193, 0.3019],
  92. [-0.3059, 0.9060, 0.2980],
  93. [-0.3892, 0.8767, 0.2883],
  94. [-0.3850, 0.7147, 0.3234],
  95. [-0.3687, 0.5255, 0.3653],
  96. [-0.3509, 0.3241, 0.4070],
  97. [-0.3323, 0.1166, 0.4457],
  98. [-0.3136, -0.0906, 0.4783],
  99. [-0.2956, -0.2913, 0.5019],
  100. [-0.2790, -0.4793, 0.5139],
  101. [-0.2642, -0.6491, 0.5118],
  102. [-0.2518, -0.7957, 0.4939],
  103. [-0.2350, -0.8722, 0.4329],
  104. [-0.2187, -0.9111, 0.3541],
  105. [-0.2063, -0.9409, 0.2747],
  106. [-0.1977, -0.9627, 0.1933],
  107. [-0.1938, -0.9768, 0.1080],
  108. [-0.1967, -0.9820, 0.0159],
  109. [-0.2114, -0.9751, -0.0883],
  110. [-0.2292, -0.9219, -0.2150],
  111. [-0.2299, -0.8091, -0.3561],
  112. [-0.2290, -0.6748, -0.5011],
  113. [-0.2253, -0.5239, -0.6460],
  114. [-0.2178, -0.3620, -0.7868],
  115. [-0.2056, -0.1948, -0.9194],
  116. [-0.1391, -0.0473, -0.9908],
  117. [-0.0476, 0.0607, -0.9987],
  118. [ 0.0215, 0.1452, -0.9909],
  119. [ 0.0725, 0.2136, -0.9759],
  120. [ 0.1114, 0.2709, -0.9579],
  121. [ 0.1426, 0.3204, -0.9383],
  122. [ 0.1690, 0.3641, -0.9177],
  123. [ 0, 0, 0],
  124. [ 0, 0, 0],
  125. [ 0, 0, 0],
  126. [ 0, 0, 0],
  127. [ 0, 0, 0],
  128. [ 0, 0, 0],
  129. [ 0, 0, 0],
  130. [-0.3734, -0.1768, 0.9125],
  131. [-0.3825, -0.2310, 0.8965],
  132. [-0.3919, -0.2895, 0.8752],
  133. [-0.4015, -0.3543, 0.8465],
  134. [-0.4108, -0.4290, 0.8065],
  135. [-0.4182, -0.5202, 0.7469],
  136. [-0.4178, -0.6423, 0.6451],
  137. [-0.3855, -0.8173, 0.4321],
  138. [-0.3110, -0.9418, 0.1401],
  139. [-0.2526, -0.9669, -0.0674],
  140. [-0.2100, -0.9541, -0.2213],
  141. [-0.1766, -0.9227, -0.3474],
  142. [-0.1491, -0.8788, -0.4570],
  143. [-0.1258, -0.8239, -0.5555],
  144. [-0.1056, -0.7583, -0.6459],
  145. [-0.0882, -0.6809, -0.7293],
  146. [-0.0734, -0.5900, -0.8061],
  147. [-0.0615, -0.4830, -0.8753],
  148. [-0.0533, -0.3556, -0.9349],
  149. [-0.0506, -0.2005, -0.9801],
  150. [-0.0575, -0.0019, -1.0000],
  151. [-0.0909, 0.2976, -0.9521],
  152. [-0.3027, 0.9509, -0.0860],
  153. [-0.2737, 0.9610, -0.0692],
  154. [-0.2524, 0.9675, -0.0596],
  155. [-0.2364, 0.9719, -0.0533],
  156. [-0.2245, 0.9749, -0.0490],
  157. [-0.2158, 0.9770, -0.0459],
  158. [-0.2097, 0.9785, -0.0439],
  159. [-0.2058, 0.9794, -0.0426],
  160. [-0.2039, 0.9798, -0.0420],
  161. [ 0, 0, 0]
  162. ])
  163. rf = np.ones(gwf.shape[0])
  164. rf[40:] = -1
  165. return gwf, rf, dt
  166. def safe_hw_check(hw):
  167. # function safe_hw_check(hw)
  168. #
  169. # Make sure that all is well with the hardware configuration.
  170. if abs(hw.x.a1 + hw.x.a2 + hw.x.a3 - 1) > 0.001 or \
  171. abs(hw.y.a1 + hw.y.a2 + hw.y.a3 - 1) > 0.001 or \
  172. abs(hw.z.a1 + hw.z.a2 + hw.z.a3 - 1) > 0.001:
  173. raise ValueError('Hardware specification a1+a2+a3 must be equal to 1!')
  174. axl = ['x', 'y', 'z']
  175. fnl = ['stim_limit', 'stim_thresh', 'tau1', 'tau2', 'tau3', 'a1', 'a2', 'a3', 'g_scale']
  176. for axn in axl:
  177. if not hasattr(hw, axn):
  178. raise ValueError(f"'{axn}' missing in hardware specification")
  179. hw_ax = getattr(hw, axn)
  180. for par in fnl:
  181. if not hasattr(hw_ax, par):
  182. raise ValueError(f"'{axn}.{par}' missing in hardware specification")
  183. def safe_longest_time_const(hw):
  184. # function ltau = safe_longest_time_const(hw)
  185. # Get the longest time constant. Can be used to estimate the size of zero
  186. # padding.
  187. return max([hw.x.tau1, hw.x.tau2, hw.x.tau3,
  188. hw.y.tau1, hw.y.tau2, hw.y.tau3,
  189. hw.z.tau1, hw.z.tau2, hw.z.tau3])
  190. def safe_pns_model(dgdt, dt, hw):
  191. # function stim = safe_pns_model(dgdt, dt, hw)
  192. #
  193. # dgdt (nx3) is in T/m/s
  194. # dt (1x1) is in s
  195. # All time coefficients (a1 and tau1 etc.) are in ms.
  196. #
  197. # This PNS model is based on the SAFE-abstract
  198. # SAFE-Model - A New Method for Predicting Peripheral Nerve Stimulations in MRI
  199. # by Franz X. Herbank and Matthias Gebhardt. Abstract No 2007.
  200. # Proc. Intl. Soc. Mag. Res. Med. 8, 2000, Denver, Colorado, USA
  201. # https://cds.ismrm.org/ismrm-2000/PDF7/2007.PDF
  202. #
  203. # The main SAFE-model was coded by Thomas Witzel @ Martinos Center,
  204. # MGH, HMS, Boston, MA, USA.
  205. #
  206. # The code was adapted/expanded/corrected by Filip Szczepankiewicz @ LMI
  207. # BWH, HMS, Boston, MA, USA, and Lund University, Sweden.
  208. stim1 = hw.a1 * abs( safe_tau_lowpass(dgdt , hw.tau1, dt * 1000) )
  209. stim2 = hw.a2 * safe_tau_lowpass(abs(dgdt), hw.tau2, dt * 1000)
  210. stim3 = hw.a3 * abs( safe_tau_lowpass(dgdt , hw.tau3, dt * 1000) )
  211. stim = (stim1 + stim2 + stim3) / hw.stim_limit * hw.g_scale * 100
  212. return stim
  213. # Not sure where something goes awry, probably in the lowpass filter, but
  214. # compared to the Siemens simulator we are exactly a factor of pi off, so
  215. # I'm dividing the final result by pi.
  216. # Note also that the final result is essentially some kind of arbitrary
  217. # unit. - TW
  218. # UPDATE 210720 - The pi factor was not quite correct. Instead, the correct
  219. # factor was determined by the gradient scale factor (hw.g_scale, defined
  220. # in the .asc file). Thanks to Maxim Zaitsev for supporting this buggfix and
  221. # validating that the updated code is accurate. - FSz
  222. def safe_tau_lowpass(dgdt, tau, dt, eps=1e-16):
  223. # function fw = safe_tau_lowpass(dgdt, tau, dt)
  224. #
  225. # Apply a RC lowpass filter with time constant tau = RC to data with sampling
  226. # interval dt. NOTE tau and dt need to be in the same unit (i.e. s or ms)
  227. # The SAFE model abstract by Hebrank et.al. just says "Lowpass with time-constant tau",
  228. # so I decided to make the most simple filter possible here.
  229. # The RC lowpass is also appealing because its something Siemens could have
  230. # easily implemented on their hardware stimulation monitors, so I'm probably
  231. # pretty close. - TW
  232. #
  233. # UPDATE 230206 - There was a factor alpha missing on the first sample it
  234. # has now been corrected. Thanks to Oliver Schad for finding this error.
  235. # - FSz
  236. alpha = dt / (tau + dt)
  237. # Calculate number of elements in filter to reach desired accuracy (eps)
  238. n = min(round(np.log(eps) / np.log(1-alpha)), dgdt.shape[0])
  239. filt = (1-alpha)**np.arange(n)
  240. # Implements lowpass filter using convolution to get rid of for loop in original code
  241. return alpha * np.convolve(dgdt, filt)[:dgdt.shape[0]]
  242. def safe_gwf_to_pns(gwf, rf, dt, hw, do_padding=True):
  243. # function [pns, res] = safe_gwf_to_pns(gwf, rf, dt, hw, doPadding)
  244. #
  245. # gwf (nx3) in T/m
  246. # dt (1x1) in s
  247. # hw (struct) is structure that describes the hardware configuration and PNS
  248. # response. Example: hw = safe_example_hw().
  249. # doPadding adds zeropadding based on the decay time.
  250. #
  251. # This PNS model is based on the SAFE-abstract
  252. # SAFE-Model - A New Method for Predicting Peripheral Nerve Stimulations in MRI
  253. # by Franz X. Herbank and Matthias Gebhardt. Abstract No 2007.
  254. # Proc. Intl. Soc. Mag. Res. Med. 8, 2000, Denver, Colorado, USA
  255. # https://cds.ismrm.org/ismrm-2000/PDF7/2007.PDF
  256. #
  257. # The main SAFE-model was coded by Thomas Witzel @ Martinos Center,
  258. # MGH, HMS, Boston, MA, USA.
  259. #
  260. # The code was adapted/expanded by Filip Szczepankiewicz @ LMI
  261. # BWH, HMS, Boston, MA, USA.
  262. if do_padding:
  263. zpt = safe_longest_time_const(hw) * 4 / 1000 # s
  264. pad1 = round(zpt/4/dt)
  265. pad2 = round(zpt/1/dt)
  266. gwf = np.pad(gwf, ((pad1, pad2), (0,0)))
  267. rf = np.pad(rf, (pad1, pad2))
  268. safe_hw_check(hw)
  269. dgdt = np.diff(gwf, axis=0) / dt
  270. pns = np.zeros(dgdt.shape)
  271. pns[:,0] = safe_pns_model(dgdt[:,0], dt, hw.x)
  272. pns[:,1] = safe_pns_model(dgdt[:,1], dt, hw.y)
  273. pns[:,2] = safe_pns_model(dgdt[:,2], dt, hw.z)
  274. # Export relevant paramters
  275. res = SimpleNamespace()
  276. res.pns = pns
  277. res.gwf = gwf
  278. res.rf = rf
  279. res.dgdt = dgdt
  280. res.dt = dt
  281. res.hw = hw
  282. return pns, res
  283. def safe_plot(pns, dt=None, envelope=True, envelope_points=500):
  284. # function h = safe_plot(pns, dt)
  285. # pns is relative PNS waveform (nx3)
  286. # dt is time step size in seconds.
  287. pnsnorm = np.sqrt((pns**2).sum(axis=1))
  288. # FZ: Added option to plot the moving maximum of pns and pnsnorm to keep
  289. # plots for long sequences intelligible
  290. if envelope and pns.shape[0] > envelope_points:
  291. N = int(np.ceil(pns.shape[0] / envelope_points))
  292. if dt != None:
  293. dt *= N
  294. if pns.shape[0] % N != 0:
  295. pns = np.concatenate((pns, np.zeros((N - pns.shape[0] % N, pns.shape[1]))))
  296. pnsnorm = np.concatenate((pnsnorm, np.zeros((N - pnsnorm.shape[0] % N))))
  297. pns = pns.reshape(pns.shape[0]//N, N, pns.shape[1])
  298. pns = pns.max(axis=1)
  299. pnsnorm = pnsnorm.reshape(pnsnorm.shape[0]//N, N)
  300. pnsnorm = pnsnorm.max(axis=1)
  301. if dt == None:
  302. ttot = 1 # au
  303. xlabstr = 'Time [a.u.]'
  304. else:
  305. ttot = pns.shape[0] * dt * 1000 # ms
  306. xlabstr = 'Time [ms]'
  307. t = np.linspace(0, ttot, pns.shape[0])
  308. plt.plot(t, pns[:,0], 'r-',
  309. t, pns[:,1], 'g-',
  310. t, pns[:,2], 'b-',
  311. t, pnsnorm , 'k-')
  312. plt.ylim([0, 120])
  313. plt.xlim([min(t), max(t)])
  314. plt.title(f'Predicted PNS ({max(pnsnorm):0.0f}%)')
  315. plt.xlabel(xlabstr)
  316. plt.ylabel('Relative stimulation [%]')
  317. plt.plot([0, max(t)], [max(pnsnorm), max(pnsnorm)], 'k:')
  318. plt.legend([f'X ({max(pns[:,0]):0.0f}%)',
  319. f'Y ({max(pns[:,1]):0.0f}%)',
  320. f'Z ({max(pns[:,2]):0.0f}%)',
  321. f'nrm ({max(pnsnorm):0.0f}%)'], loc='best')
  322. def safe_example():
  323. # Load an exampe gradient waveform
  324. [gwf, rf, dt] = safe_example_gwf()
  325. # Load reponse parameters for example hardware
  326. hw = safe_example_hw()
  327. # Check if hardware parameters are consistent
  328. safe_hw_check(hw)
  329. # Check if this hw is part of the library (validate hw)
  330. # safe_hw_verify(hw)
  331. # Predict PNS levels
  332. pns, res = safe_gwf_to_pns(gwf, rf, dt, hw, 1)
  333. # Plot some results
  334. safe_plot(pns, dt)
  335. if __name__ == '__main__':
  336. safe_example()