Bladeren bron

rebuild from scrap

spacexerq 1 maand geleden
bovenliggende
commit
fd1f64f4a4
519 gewijzigde bestanden met toevoegingen van 51070 en 159 verwijderingen
  1. 21 0
      .dockerignore
  2. 47 0
      .gitignore
  3. 51 15
      Makefile
  4. 216 0
      apps/gui/ARCHITECTURE.md
  5. 100 0
      apps/gui/api.py
  6. 85 0
      apps/gui/app.py
  7. 15 0
      apps/gui/build_exe.bat
  8. 46 0
      apps/gui/cfg/hw_config.json
  9. 10 0
      apps/gui/cfg/server_config.json
  10. 10 0
      apps/gui/cfg/updated_constraints_lf.json
  11. 106 0
      apps/gui/lf_mri_gui.spec
  12. 11 0
      apps/gui/requirements.txt
  13. 0 0
      apps/gui/src/__init__.py
  14. 434 0
      apps/gui/src/app_window.py
  15. 0 0
      apps/gui/src/clients/__init__.py
  16. 95 0
      apps/gui/src/clients/orchestrator_client.py
  17. 123 0
      apps/gui/src/clients/seq_interp_client.py
  18. 37 0
      apps/gui/src/config.py
  19. 0 0
      apps/gui/src/core/__init__.py
  20. 27 0
      apps/gui/src/core/sequence_generator.py
  21. 76 0
      apps/gui/src/core/synchronizer.py
  22. 29 0
      apps/gui/src/core/waveform_processor.py
  23. 0 0
      apps/gui/src/fid/__init__.py
  24. 62 0
      apps/gui/src/fid/seqgen_FID.py
  25. 0 0
      apps/gui/src/gui/__init__.py
  26. 210 0
      apps/gui/src/gui/adapters.py
  27. 150 0
      apps/gui/src/gui/block_table.py
  28. 137 0
      apps/gui/src/gui/controls_panel.py
  29. 519 0
      apps/gui/src/gui/plot_panel.py
  30. 145 0
      apps/gui/src/gui/preview_panel.py
  31. 294 0
      apps/gui/src/gui/scheme_panel.py
  32. 293 0
      apps/gui/src/gui/workers.py
  33. 0 0
      apps/gui/src/hardware/__init__.py
  34. 65 0
      apps/gui/src/hardware/constraints.py
  35. 0 0
      apps/gui/src/interfaces/__init__.py
  36. 44 0
      apps/gui/src/interfaces/gradient_exporter.py
  37. 105 0
      apps/gui/src/interfaces/nnet_exporter.py
  38. 48 0
      apps/gui/src/interfaces/picoscope_exporter.py
  39. 141 0
      apps/gui/src/interfaces/post_request_generator.py
  40. 92 0
      apps/gui/src/interfaces/pulseq_adapter.py
  41. 57 0
      apps/gui/src/interfaces/rf_exporter.py
  42. 69 0
      apps/gui/src/interfaces/xml_generator.py
  43. 50 0
      apps/gui/src/server_worker.py
  44. 0 0
      apps/gui/src/tabs/__init__.py
  45. 409 0
      apps/gui/src/tabs/fid_tab.py
  46. 457 0
      apps/gui/src/tabs/scanner_tab.py
  47. 816 0
      apps/gui/src/tabs/scanning_tab.py
  48. 667 0
      apps/gui/src/tabs/seq_interp_tab.py
  49. 0 0
      apps/gui/src/utils/__init__.py
  50. 15 0
      apps/gui/src/utils/cumsum.py
  51. 8 0
      apps/gui/src/utils/dataclass.py
  52. 117 0
      apps/gui/src/utils/vizualizator.py
  53. 32 63
      docker-compose.yml
  54. 37 52
      install.ps1
  55. 74 0
      libs/lf-scanner/CODE_OF_CONDUCT.md
  56. 30 0
      libs/lf-scanner/CONTRIBUTING.md
  57. 102 0
      libs/lf-scanner/FID_from_scratch.ipynb
  58. 661 0
      libs/lf-scanner/LICENSE
  59. 3 0
      libs/lf-scanner/MANIFEST.in
  60. 201 0
      libs/lf-scanner/README.md
  61. 39 0
      libs/lf-scanner/TSE_20231019_161845.json
  62. 762 0
      libs/lf-scanner/TSE_pulse_sequence-Copy1.ipynb
  63. 762 0
      libs/lf-scanner/TSE_pulse_sequence.ipynb
  64. 762 0
      libs/lf-scanner/TSE_pulse_sequence_T1.ipynb
  65. 722 0
      libs/lf-scanner/TSE_splited_gradients.ipynb
  66. 628 0
      libs/lf-scanner/TSE_splited_gradients_RESTORE.ipynb
  67. 0 0
      libs/lf-scanner/__init__.py
  68. 20 0
      libs/lf-scanner/doc/Makefile
  69. 0 0
      libs/lf-scanner/doc/__init__.py
  70. 35 0
      libs/lf-scanner/doc/make.bat
  71. 2 0
      libs/lf-scanner/doc/readthedocs_requirements.txt
  72. 0 0
      libs/lf-scanner/doc/source/__init__.py
  73. 66 0
      libs/lf-scanner/doc/source/conf.py
  74. 25 0
      libs/lf-scanner/doc/source/index.rst
  75. 9 0
      libs/lf-scanner/doc/source/modules.rst
  76. 21 0
      libs/lf-scanner/doc/source/pypulseq.SAR.rst
  77. 61 0
      libs/lf-scanner/doc/source/pypulseq.Sequence.rst
  78. 319 0
      libs/lf-scanner/doc/source/pypulseq.rst
  79. 117 0
      libs/lf-scanner/doc/source/pypulseq.tests.rst
  80. 7 0
      libs/lf-scanner/doc/source/setup.rst
  81. 7 0
      libs/lf-scanner/doc/source/version.rst
  82. BIN
      libs/lf-scanner/doc/walkthrough/gre_1.png
  83. BIN
      libs/lf-scanner/doc/walkthrough/gre_2.png
  84. 84 0
      libs/lf-scanner/doc/walkthrough/gre_walkthrough.ipynb
  85. BIN
      libs/lf-scanner/logo.png
  86. BIN
      libs/lf-scanner/logo_transparent.png
  87. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/__init__.py
  88. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/pd_TSE_matrx16x16_fixed_delay.xml
  89. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/pd_TSE_matrx16x16_myGrad.xml
  90. BIN
      libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/rf_1.h5
  91. BIN
      libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/rf_2.h5
  92. BIN
      libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/rf_3.h5
  93. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_FS_TSE/FS_T1_TSE_1.png
  94. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_FS_TSE/FS_T1_TSE_2.png
  95. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/__init__.py
  96. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/rf_1.h5
  97. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/rf_2.h5
  98. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/rf_3.h5
  99. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/rf_4.h5
  100. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/t1_TSE_matrx16x16_fixed_delay.xml
  101. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/t1_TSE_matrx16x16_myGrad.xml
  102. 432 0
      libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/write_TSE_T1.py
  103. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_se/rf_1.h5
  104. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_se/rf_2.h5
  105. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_se/rf_3.h5
  106. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_se/rf_4.h5
  107. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t1_se/with_gxspoi_without_phases_offsets/different_contrasts.pptx
  108. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/IR_t2_TSE_matrx16x16_myGrad.xml
  109. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/T2_STIR_TSE_1.png
  110. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/T2_STIR_TSE_2.png
  111. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/rf_1.h5
  112. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/rf_2.h5
  113. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/rf_3.h5
  114. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/rf_1.h5
  115. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/rf_2.h5
  116. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/rf_3.h5
  117. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/t2_TSE_matrx16x16_fixed_delay.xml
  118. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/t2_TSE_matrx16x16_myGrad.xml
  119. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/__init__.py
  120. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/rf_1.h5
  121. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/rf_2.h5
  122. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/rf_3.h5
  123. BIN
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/rf_4.h5
  124. 0 0
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/t2_TSE_RESTORE_matrx16x16_myGrad.xml
  125. 289 0
      libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/write_TSE_T2_RESTORE.py
  126. 106 0
      libs/lf-scanner/paper.bib
  127. 106 0
      libs/lf-scanner/paper.md
  128. 31 0
      libs/lf-scanner/py2jemris/.github/ISSUE_TEMPLATE/bug_report.md
  129. 20 0
      libs/lf-scanner/py2jemris/.github/ISSUE_TEMPLATE/feature_request.md
  130. 120 0
      libs/lf-scanner/py2jemris/.gitignore
  131. 76 0
      libs/lf-scanner/py2jemris/CODE_OF_CONDUCT.md
  132. 27 0
      libs/lf-scanner/py2jemris/CONTRIBUTING.md
  133. 674 0
      libs/lf-scanner/py2jemris/LICENSE
  134. 27 0
      libs/lf-scanner/py2jemris/README.md
  135. 0 0
      libs/lf-scanner/py2jemris/__init__.py
  136. 1 0
      libs/lf-scanner/py2jemris/benchmark_seq2xml/.jemris_progress.out
  137. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/.spins_state.dat
  138. 0 0
      libs/lf-scanner/py2jemris/benchmark_seq2xml/__init__.py
  139. 64 0
      libs/lf-scanner/py2jemris/benchmark_seq2xml/benchmark_seq_files.py
  140. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/ext_rf.h5
  141. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/gre.h5
  142. 23 0
      libs/lf-scanner/py2jemris/benchmark_seq2xml/gre.xml
  143. 0 0
      libs/lf-scanner/py2jemris/benchmark_seq2xml/gre_jemris_seq2xml.xml
  144. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/gre_jemris_seq2xml_jemris.h5
  145. 1 0
      libs/lf-scanner/py2jemris/benchmark_seq2xml/mysimu2.xml
  146. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_1.h5
  147. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_2.h5
  148. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_3.h5
  149. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_4.h5
  150. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_5.h5
  151. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_6.h5
  152. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_7.h5
  153. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_8.h5
  154. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/sample.h5
  155. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/seq_compare.PNG
  156. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/seq_compare_zoomed.PNG
  157. BIN
      libs/lf-scanner/py2jemris/benchmark_seq2xml/signals.h5
  158. 4 0
      libs/lf-scanner/py2jemris/benchmark_seq2xml/uniform.xml
  159. 115 0
      libs/lf-scanner/py2jemris/coil2xml.py
  160. 30 0
      libs/lf-scanner/py2jemris/examine_seq_diag.py
  161. 15 0
      libs/lf-scanner/py2jemris/make_some_seqs.py
  162. 597 0
      libs/lf-scanner/py2jemris/phantom.py
  163. 33 0
      libs/lf-scanner/py2jemris/pull_request_template.md
  164. 210 0
      libs/lf-scanner/py2jemris/pulseq_jemris_simulator.py
  165. 1150 0
      libs/lf-scanner/py2jemris/pulseq_library.py
  166. 2223 0
      libs/lf-scanner/py2jemris/py2jemris_demo.ipynb
  167. 180 0
      libs/lf-scanner/py2jemris/recon_jemris.py
  168. 42 0
      libs/lf-scanner/py2jemris/record_seq2xml_times.py
  169. 5 0
      libs/lf-scanner/py2jemris/requirements.txt
  170. BIN
      libs/lf-scanner/py2jemris/rf_1.h5
  171. BIN
      libs/lf-scanner/py2jemris/rf_10.h5
  172. BIN
      libs/lf-scanner/py2jemris/rf_11.h5
  173. BIN
      libs/lf-scanner/py2jemris/rf_12.h5
  174. BIN
      libs/lf-scanner/py2jemris/rf_13.h5
  175. BIN
      libs/lf-scanner/py2jemris/rf_14.h5
  176. BIN
      libs/lf-scanner/py2jemris/rf_15.h5
  177. BIN
      libs/lf-scanner/py2jemris/rf_16.h5
  178. BIN
      libs/lf-scanner/py2jemris/rf_17.h5
  179. BIN
      libs/lf-scanner/py2jemris/rf_18.h5
  180. BIN
      libs/lf-scanner/py2jemris/rf_19.h5
  181. BIN
      libs/lf-scanner/py2jemris/rf_2.h5
  182. BIN
      libs/lf-scanner/py2jemris/rf_20.h5
  183. BIN
      libs/lf-scanner/py2jemris/rf_21.h5
  184. BIN
      libs/lf-scanner/py2jemris/rf_22.h5
  185. BIN
      libs/lf-scanner/py2jemris/rf_23.h5
  186. BIN
      libs/lf-scanner/py2jemris/rf_24.h5
  187. BIN
      libs/lf-scanner/py2jemris/rf_25.h5
  188. BIN
      libs/lf-scanner/py2jemris/rf_3.h5
  189. BIN
      libs/lf-scanner/py2jemris/rf_4.h5
  190. BIN
      libs/lf-scanner/py2jemris/rf_5.h5
  191. BIN
      libs/lf-scanner/py2jemris/rf_6.h5
  192. BIN
      libs/lf-scanner/py2jemris/rf_7.h5
  193. BIN
      libs/lf-scanner/py2jemris/rf_8.h5
  194. BIN
      libs/lf-scanner/py2jemris/rf_9.h5
  195. BIN
      libs/lf-scanner/py2jemris/sample.h5
  196. 307 0
      libs/lf-scanner/py2jemris/seq2xml.py
  197. 318 0
      libs/lf-scanner/py2jemris/seq2xml_fixed_delay.py
  198. 40 0
      libs/lf-scanner/py2jemris/sim/8chheadcyl.xml
  199. 0 0
      libs/lf-scanner/py2jemris/sim/__init__.py
  200. 22 0
      libs/lf-scanner/py2jemris/sim/epi.xml
  201. 23 0
      libs/lf-scanner/py2jemris/sim/gre.xml
  202. BIN
      libs/lf-scanner/py2jemris/sim/ismrm_abstract/spgr_64_v2/phantom_bottles.mat
  203. BIN
      libs/lf-scanner/py2jemris/sim/sample.h5
  204. 31 0
      libs/lf-scanner/py2jemris/sim/tse.xml
  205. 4 0
      libs/lf-scanner/py2jemris/sim/uniform.xml
  206. BIN
      libs/lf-scanner/py2jemris/sim/utest_outputs/cylindrical.h5
  207. BIN
      libs/lf-scanner/py2jemris/sim/utest_outputs/data32_orig.mat
  208. 28 0
      libs/lf-scanner/py2jemris/sim/utest_outputs/gre32.xml
  209. BIN
      libs/lf-scanner/py2jemris/sim/utest_outputs/signals.h5
  210. 1 0
      libs/lf-scanner/py2jemris/sim/utest_outputs/simu.xml
  211. 4 0
      libs/lf-scanner/py2jemris/sim/utest_outputs/uniform.xml
  212. 53 0
      libs/lf-scanner/py2jemris/sim2xml.py
  213. 181 0
      libs/lf-scanner/py2jemris/sim_jemris.py
  214. 37 0
      libs/lf-scanner/py2jemris/sim_py2jemris_ismrm2021.py
  215. 38 0
      libs/lf-scanner/py2jemris/sim_seq_validation.py
  216. 181 0
      libs/lf-scanner/py2jemris/utest_py2jemris_script.py
  217. BIN
      libs/lf-scanner/pypulseq/SAR/QGlobal.mat
  218. 325 0
      libs/lf-scanner/pypulseq/SAR/SAR_calc.py
  219. 0 0
      libs/lf-scanner/pypulseq/SAR/__init__.py
  220. 0 0
      libs/lf-scanner/pypulseq/Sequence/__init__.py
  221. 637 0
      libs/lf-scanner/pypulseq/Sequence/block.py
  222. 179 0
      libs/lf-scanner/pypulseq/Sequence/calc_grad_spectrum.py
  223. 102 0
      libs/lf-scanner/pypulseq/Sequence/calc_pns.py
  224. 247 0
      libs/lf-scanner/pypulseq/Sequence/ext_test_report.py
  225. 86 0
      libs/lf-scanner/pypulseq/Sequence/parula.py
  226. 660 0
      libs/lf-scanner/pypulseq/Sequence/read_seq.py
  227. 1872 0
      libs/lf-scanner/pypulseq/Sequence/sequence.py
  228. 269 0
      libs/lf-scanner/pypulseq/Sequence/write_seq.py
  229. 55 0
      libs/lf-scanner/pypulseq/__init__.py
  230. 222 0
      libs/lf-scanner/pypulseq/add_gradients.py
  231. 92 0
      libs/lf-scanner/pypulseq/add_ramps.py
  232. 81 0
      libs/lf-scanner/pypulseq/align.py
  233. 53 0
      libs/lf-scanner/pypulseq/block_to_events.py
  234. 61 0
      libs/lf-scanner/pypulseq/calc_duration.py
  235. 355 0
      libs/lf-scanner/pypulseq/calc_ramp.py
  236. 65 0
      libs/lf-scanner/pypulseq/calc_rf_bandwidth.py
  237. 31 0
      libs/lf-scanner/pypulseq/calc_rf_center.py
  238. 119 0
      libs/lf-scanner/pypulseq/check_timing.py
  239. 76 0
      libs/lf-scanner/pypulseq/compress_shape.py
  240. 91 0
      libs/lf-scanner/pypulseq/convert.py
  241. 74 0
      libs/lf-scanner/pypulseq/decompress_shape.py
  242. 316 0
      libs/lf-scanner/pypulseq/event_lib.py
  243. 66 0
      libs/lf-scanner/pypulseq/make_adc.py
  244. 262 0
      libs/lf-scanner/pypulseq/make_adiabatic_pulse.py
  245. 77 0
      libs/lf-scanner/pypulseq/make_arbitrary_grad.py
  246. 154 0
      libs/lf-scanner/pypulseq/make_arbitrary_rf.py
  247. 106 0
      libs/lf-scanner/pypulseq/make_block_pulse.py
  248. 30 0
      libs/lf-scanner/pypulseq/make_delay.py
  249. 47 0
      libs/lf-scanner/pypulseq/make_digital_output_pulse.py
  250. 143 0
      libs/lf-scanner/pypulseq/make_extended_trapezoid.py
  251. 133 0
      libs/lf-scanner/pypulseq/make_extended_trapezoid_area.py
  252. 179 0
      libs/lf-scanner/pypulseq/make_gauss_pulse.py
  253. 56 0
      libs/lf-scanner/pypulseq/make_label.py
  254. 268 0
      libs/lf-scanner/pypulseq/make_sigpy_pulse.py
  255. 172 0
      libs/lf-scanner/pypulseq/make_sinc_pulse.py
  256. 203 0
      libs/lf-scanner/pypulseq/make_trapezoid.py
  257. 53 0
      libs/lf-scanner/pypulseq/make_trigger.py
  258. 110 0
      libs/lf-scanner/pypulseq/opts.py
  259. 41 0
      libs/lf-scanner/pypulseq/points_to_waveform.py
  260. 20 0
      libs/lf-scanner/pypulseq/recon_examples/2dFFT.py
  261. 0 0
      libs/lf-scanner/pypulseq/recon_examples/__init__.py
  262. 123 0
      libs/lf-scanner/pypulseq/rotate.py
  263. 35 0
      libs/lf-scanner/pypulseq/scale_grad.py
  264. 0 0
      libs/lf-scanner/pypulseq/seq_examples/__init__.py
  265. 0 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/__init__.py
  266. 339 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_FS_TSE_T1_T2_PD.py
  267. 287 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_HASTE_T2.py
  268. 396 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_IR_TSE_T1_T2.py
  269. 213 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_SE.py
  270. 452 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_SPAIR_TSE_T2.py
  271. 289 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_TSE_T1_T2_PD.py
  272. 289 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_TSE_T2_RESTORE.py
  273. 264 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_epi_SE_T2.py
  274. 286 0
      libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_tse.py
  275. 449 0
      libs/lf-scanner/pypulseq/seq_examples/notebooks/write_t2_se.ipynb
  276. 43 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/README.md
  277. 0 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/__init__.py
  278. 100 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/demo_read.py
  279. BIN
      libs/lf-scanner/pypulseq/seq_examples/scripts/example_recons/gre.png
  280. BIN
      libs/lf-scanner/pypulseq/seq_examples/scripts/example_recons/tse.png
  281. 129 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_2Dt1_mprage.py
  282. 155 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_3Dt1_mprage.py
  283. 196 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_MPRAGE.py
  284. 114 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_epi.py
  285. 174 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_epi_label.py
  286. 139 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_epi_se.py
  287. 287 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_epi_se_rs.py
  288. 158 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_gre.py
  289. 169 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_gre_label.py
  290. 326 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_haste.py
  291. 142 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_radial_gre.py
  292. 332 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_tse.py
  293. 347 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_tse_new.py
  294. 180 0
      libs/lf-scanner/pypulseq/seq_examples/scripts/write_ute.py
  295. 41 0
      libs/lf-scanner/pypulseq/sigpy_pulse_opts.py
  296. 93 0
      libs/lf-scanner/pypulseq/split_gradient.py
  297. 147 0
      libs/lf-scanner/pypulseq/split_gradient_at.py
  298. 37 0
      libs/lf-scanner/pypulseq/supported_labels_rf_use.py
  299. 0 0
      libs/lf-scanner/pypulseq/tests/__init__.py
  300. 28 0
      libs/lf-scanner/pypulseq/tests/base.py
  301. 19 0
      libs/lf-scanner/pypulseq/tests/test_MPRAGE.py
  302. 19 0
      libs/lf-scanner/pypulseq/tests/test_epi.py
  303. 19 0
      libs/lf-scanner/pypulseq/tests/test_epi_label.py
  304. 19 0
      libs/lf-scanner/pypulseq/tests/test_epi_se.py
  305. 19 0
      libs/lf-scanner/pypulseq/tests/test_epi_se_rs.py
  306. 19 0
      libs/lf-scanner/pypulseq/tests/test_gre.py
  307. 19 0
      libs/lf-scanner/pypulseq/tests/test_gre_label.py
  308. 19 0
      libs/lf-scanner/pypulseq/tests/test_gre_radial.py
  309. 19 0
      libs/lf-scanner/pypulseq/tests/test_haste.py
  310. 120 0
      libs/lf-scanner/pypulseq/tests/test_sigpy.py
  311. 19 0
      libs/lf-scanner/pypulseq/tests/test_tse.py
  312. 19 0
      libs/lf-scanner/pypulseq/tests/test_ute.py
  313. 39 0
      libs/lf-scanner/pypulseq/traj_to_grad.py
  314. 40 0
      libs/lf-scanner/pypulseq/utilities/TSE_k_space_fill.py
  315. 0 0
      libs/lf-scanner/pypulseq/utilities/__init__.py
  316. 39 0
      libs/lf-scanner/pypulseq/utilities/magn_prep/FS_CHESS_block.py
  317. 32 0
      libs/lf-scanner/pypulseq/utilities/magn_prep/IR_block.py
  318. 43 0
      libs/lf-scanner/pypulseq/utilities/magn_prep/SPAIR_block.py
  319. 0 0
      libs/lf-scanner/pypulseq/utilities/magn_prep/__init__.py
  320. 68 0
      libs/lf-scanner/pypulseq/utilities/magn_prep/magn_prep.py
  321. 17 0
      libs/lf-scanner/pypulseq/utilities/phase_grad_utils.py
  322. 188 0
      libs/lf-scanner/pypulseq/utilities/standart_RF.py
  323. 0 0
      libs/lf-scanner/pypulseq/utils/SAR/__init__.py
  324. 0 0
      libs/lf-scanner/pypulseq/utils/__init__.py
  325. 15 0
      libs/lf-scanner/pypulseq/utils/cumsum.py
  326. 411 0
      libs/lf-scanner/pypulseq/utils/safe_pns_prediction.py
  327. 1 0
      libs/lf-scanner/pypulseq/utils/siemens/__init__.py
  328. 105 0
      libs/lf-scanner/pypulseq/utils/siemens/asc_to_hw.py
  329. 97 0
      libs/lf-scanner/pypulseq/utils/siemens/readasc.py
  330. BIN
      libs/lf-scanner/rf_1.h5
  331. BIN
      libs/lf-scanner/rf_2.h5
  332. BIN
      libs/lf-scanner/rf_3.h5
  333. BIN
      libs/lf-scanner/rf_4.h5
  334. BIN
      libs/lf-scanner/rf_5.h5
  335. BIN
      libs/lf-scanner/rf_6.h5
  336. BIN
      libs/lf-scanner/rf_7.h5
  337. BIN
      libs/lf-scanner/rf_8.h5
  338. 0 0
      libs/lf-scanner/services/Protocol/__init__.py
  339. 14 0
      libs/lf-scanner/services/Protocol/protocol.py
  340. 4 0
      libs/lf-scanner/services/__init__.py
  341. 348 0
      libs/lf-scanner/services/srv_interp.py
  342. 0 0
      libs/lf-scanner/services/srv_stack.py
  343. 53 0
      libs/lf-scanner/setup.py
  344. 509 0
      libs/lf-scanner/t1_SE.ipynb
  345. 626 0
      libs/lf-scanner/t1_SE_experimental.ipynb
  346. 346 0
      libs/lf-scanner/t1_SE_final.ipynb
  347. 477 0
      libs/lf-scanner/t1_SE_final_final.ipynb
  348. 424 0
      libs/lf-scanner/t1_SE_final_max_grad.ipynb
  349. 499 0
      libs/lf-scanner/t2_SE_backup.ipynb
  350. 66 0
      libs/lf-scanner/t2_SE_original.ipynb
  351. 0 0
      libs/lf-scanner/t2_se_pypulseq_colab.xml
  352. 0 0
      libs/lf-scanner/utilities/__init__.py
  353. 16 0
      libs/lf-scanner/utilities/phase_grad_utils.py
  354. 5 0
      libs/lf-scanner/version.py
  355. 448 0
      libs/lf-scanner/write_se_new.ipynb
  356. 463 0
      libs/lf-scanner/write_t2_se.ipynb
  357. 18 0
      services/orchestrator/Dockerfile
  358. 0 0
      services/orchestrator/orchestrator/__init__.py
  359. 0 0
      services/orchestrator/orchestrator/clients/__init__.py
  360. 61 0
      services/orchestrator/orchestrator/clients/reco_cl.py
  361. 109 0
      services/orchestrator/orchestrator/clients/spec_cl.py
  362. 25 0
      services/orchestrator/orchestrator/docstore.py
  363. 51 0
      services/orchestrator/orchestrator/healthcheck.py
  364. 183 0
      services/orchestrator/orchestrator/main.py
  365. 65 0
      services/orchestrator/orchestrator/run_scenario.py
  366. 64 0
      services/orchestrator/orchestrator/scenario.py
  367. 15 0
      services/orchestrator/orchestrator/scenario_loader.py
  368. 101 0
      services/orchestrator/orchestrator/signal_decoder.py
  369. 24 0
      services/orchestrator/orchestrator/tasks_plug.py
  370. 63 0
      services/orchestrator/orchestrator/tasks_real.py
  371. 6 0
      services/orchestrator/requirements.txt
  372. 22 0
      services/orchestrator/scenarios/full_pipeline.yaml
  373. 4 0
      services/orchestrator/scenarios/only_reconstruction.yaml
  374. 20 0
      services/reconstructor/Dockerfile
  375. 74 0
      services/reconstructor/EPI_SE_240824_1418.json
  376. 76 0
      services/reconstructor/MESE_080825_0144.json
  377. 0 0
      services/reconstructor/api/__init__.py
  378. 11 0
      services/reconstructor/api/core.py
  379. 15 0
      services/reconstructor/api/main.py
  380. 380 0
      services/reconstructor/api/recon_app.py
  381. 0 0
      services/reconstructor/api/routers/__init__.py
  382. 8 0
      services/reconstructor/api/routers/files.py
  383. 8 0
      services/reconstructor/api/routers/health.py
  384. 138 0
      services/reconstructor/api/routers/reconstruct.py
  385. 30 0
      services/reconstructor/api/schemas.py
  386. 51 0
      services/reconstructor/api/session.py
  387. 20 0
      services/reconstructor/api/storage.py
  388. 32 0
      services/reconstructor/api/utils.py
  389. 135 0
      services/reconstructor/client_reco_test.py
  390. 190 0
      services/reconstructor/reco.py
  391. 211 0
      services/reconstructor/reconsrtuctionApp.py
  392. 7 0
      services/reconstructor/requirements.txt
  393. 36 0
      services/reconstructor/seqData.py
  394. 31 0
      services/reconstructor/servUI.py
  395. 45 0
      services/reconstructor/service.py
  396. 159 0
      services/reconstructor/startApp.py
  397. 116 0
      services/reconstructor/test.py
  398. 200 0
      services/reconstructor/traj.py
  399. 32 0
      services/seq-interp/Dockerfile
  400. 53 0
      services/seq-interp/POST_request_mes.txt
  401. 1 0
      services/seq-interp/VERSION
  402. 0 0
      services/seq-interp/__init__.py
  403. 188 0
      services/seq-interp/api.py
  404. 46 0
      services/seq-interp/cfg/hw_config.json
  405. 8 0
      services/seq-interp/cfg/server_config.json
  406. 10 0
      services/seq-interp/cfg/updated_constraints_lf.json
  407. 24 0
      services/seq-interp/docs/README.md
  408. 88 0
      services/seq-interp/gui_app.py
  409. 13 0
      services/seq-interp/requirements.docker.txt
  410. 1 0
      services/seq-interp/requirements.txt
  411. 10 0
      services/seq-interp/server.py
  412. 0 0
      services/seq-interp/src/__init__.py
  413. 37 0
      services/seq-interp/src/config.py
  414. 0 0
      services/seq-interp/src/core/__init__.py
  415. 27 0
      services/seq-interp/src/core/sequence_generator.py
  416. 76 0
      services/seq-interp/src/core/synchronizer.py
  417. 29 0
      services/seq-interp/src/core/waveform_processor.py
  418. 262 0
      services/seq-interp/src/fid_gui/gui_RF_adj(FID).py
  419. 229 0
      services/seq-interp/src/fid_gui/gui_RF_adj(FID)_console.py
  420. 56 0
      services/seq-interp/src/fid_gui/seqgen_FID.py
  421. 655 0
      services/seq-interp/src/fid_gui/srv_interp.py
  422. 0 0
      services/seq-interp/src/gui/__init__.py
  423. 210 0
      services/seq-interp/src/gui/adapters.py
  424. 150 0
      services/seq-interp/src/gui/block_table.py
  425. 137 0
      services/seq-interp/src/gui/controls_panel.py
  426. 600 0
      services/seq-interp/src/gui/main_window.py
  427. 519 0
      services/seq-interp/src/gui/plot_panel.py
  428. 145 0
      services/seq-interp/src/gui/preview_panel.py
  429. 294 0
      services/seq-interp/src/gui/scheme_panel.py
  430. 228 0
      services/seq-interp/src/gui/workers.py
  431. 0 0
      services/seq-interp/src/hardware/__init__.py
  432. 65 0
      services/seq-interp/src/hardware/constraints.py
  433. 0 0
      services/seq-interp/src/interfaces/__init__.py
  434. 44 0
      services/seq-interp/src/interfaces/gradient_exporter.py
  435. 105 0
      services/seq-interp/src/interfaces/nnet_exporter.py
  436. 48 0
      services/seq-interp/src/interfaces/picoscope_exporter.py
  437. 141 0
      services/seq-interp/src/interfaces/post_request_generator.py
  438. 92 0
      services/seq-interp/src/interfaces/pulseq_adapter.py
  439. 57 0
      services/seq-interp/src/interfaces/rf_exporter.py
  440. 69 0
      services/seq-interp/src/interfaces/xml_generator.py
  441. 103 0
      services/seq-interp/src/main.py
  442. 0 0
      services/seq-interp/src/utils/__init__.py
  443. 15 0
      services/seq-interp/src/utils/cumsum.py
  444. 8 0
      services/seq-interp/src/utils/dataclass.py
  445. 117 0
      services/seq-interp/src/utils/vizualizator.py
  446. 0 0
      services/seq-interp/tests/__init__.py
  447. 304 0
      services/seq-interp/tests/test_adapters.py
  448. 29 0
      services/seq-interp/tests/test_pulseq_loader.py
  449. 54 0
      services/seq-interp/tests/test_synchronizer.py
  450. 53 0
      services/seq-interp/tests/test_xml_generator.py
  451. 36 0
      services/seq-interp/tests/waveform_processor.py
  452. 7 0
      services/spectrometer/AddDevices.bat
  453. 27 0
      services/spectrometer/CreateDB.sql
  454. 23 0
      services/spectrometer/Dockerfile
  455. 1 0
      services/spectrometer/VERSION
  456. 1 0
      services/spectrometer/autokill.bat
  457. 6 0
      services/spectrometer/autorun_default.bat
  458. 3 0
      services/spectrometer/autorun_default_simple.bat
  459. 7 0
      services/spectrometer/autorun_default_venv.bat
  460. BIN
      services/spectrometer/bin/Owin.dll
  461. BIN
      services/spectrometer/bin/Sync.exe
  462. BIN
      services/spectrometer/bin/hackrf.dll
  463. BIN
      services/spectrometer/bin/hackrfdll00.dll
  464. BIN
      services/spectrometer/bin/hackrftrans00.exe
  465. BIN
      services/spectrometer/bin/libgcc_s_seh-1.dll
  466. BIN
      services/spectrometer/bin/libstdc++-6.dll
  467. BIN
      services/spectrometer/bin/libwinpthread-1.dll
  468. BIN
      services/spectrometer/bin/msys-usb-1.0.dll
  469. BIN
      services/spectrometer/bin/pico-tcp.exe
  470. BIN
      services/spectrometer/bin/picocv.dll
  471. BIN
      services/spectrometer/bin/picoipp.dll
  472. 3 0
      services/spectrometer/bin/picologs/pico-log-20250807-171834.txt
  473. BIN
      services/spectrometer/bin/picologs/pico-log-20250807-171848.txt
  474. 3 0
      services/spectrometer/bin/picologs/pico-log-20250822-124725.txt
  475. 3 0
      services/spectrometer/bin/picologs/pico-log-20250822-140901.txt
  476. 22 0
      services/spectrometer/bin/picologs/pico-log-20250822-140915.txt
  477. BIN
      services/spectrometer/bin/picologs/pico-log-20250822-141057.txt
  478. BIN
      services/spectrometer/bin/picologs/pico-log-20250822-141229.txt
  479. BIN
      services/spectrometer/bin/picologs/pico-log-20250822-143438.txt
  480. BIN
      services/spectrometer/bin/picologs/pico-log-20250822-150026.txt
  481. BIN
      services/spectrometer/bin/picologs/pico-log-20250822-152732.txt
  482. 3 0
      services/spectrometer/bin/picologs/pico-log-20250827-121403.txt
  483. BIN
      services/spectrometer/bin/picologs/pico-log-20250827-123005.txt
  484. BIN
      services/spectrometer/bin/picologs/pico-log-20250827-130006.txt
  485. BIN
      services/spectrometer/bin/picologs/pico-log-20250827-134103.txt
  486. BIN
      services/spectrometer/bin/picologs/pico-log-20250827-134238.txt
  487. BIN
      services/spectrometer/bin/ps4000a.dll
  488. BIN
      services/spectrometer/bin/ps4000a.lib
  489. 15 0
      services/spectrometer/docker-compose.yml
  490. 22 0
      services/spectrometer/manage.py
  491. 0 0
      services/spectrometer/mserv00/__init__.py
  492. 16 0
      services/spectrometer/mserv00/asgi.py
  493. 144 0
      services/spectrometer/mserv00/settings.py
  494. 38 0
      services/spectrometer/mserv00/urls.py
  495. 16 0
      services/spectrometer/mserv00/wsgi.py
  496. 3 0
      services/spectrometer/readme.txt
  497. 22 0
      services/spectrometer/requirements.txt
  498. 0 0
      services/spectrometer/spectrometer/__init__.py
  499. 16 0
      services/spectrometer/spectrometer/admin.py
  500. 6 0
      services/spectrometer/spectrometer/apps.py
  501. 585 0
      services/spectrometer/spectrometer/engine.py
  502. 343 0
      services/spectrometer/spectrometer/interfaces.py
  503. 156 0
      services/spectrometer/spectrometer/migrations/0001_initial.py
  504. 18 0
      services/spectrometer/spectrometer/migrations/0002_measurement_data_channel_data.py
  505. 17 0
      services/spectrometer/spectrometer/migrations/0003_remove_channel_data_measurement_data.py
  506. 18 0
      services/spectrometer/spectrometer/migrations/0004_state_engine.py
  507. 22 0
      services/spectrometer/spectrometer/migrations/0005_remove_state_engine_measurement_info_engine.py
  508. 24 0
      services/spectrometer/spectrometer/migrations/0006_channel_data_measurement_data_and_more.py
  509. 23 0
      services/spectrometer/spectrometer/migrations/0007_adc_params_averaging_measurement_data_averaging_num.py
  510. 0 0
      services/spectrometer/spectrometer/migrations/__init__.py
  511. 136 0
      services/spectrometer/spectrometer/models.py
  512. 167 0
      services/spectrometer/spectrometer/serializers.py
  513. 11 0
      services/spectrometer/spectrometer/templates/spectrometer/index.html
  514. 3 0
      services/spectrometer/spectrometer/tests.py
  515. 6 0
      services/spectrometer/spectrometer/urls.py
  516. 196 0
      services/spectrometer/spectrometer/views.py
  517. 12 0
      services/spectrometer/temp
  518. 1 0
      services/spectroscopy
  519. 18 29
      start.ps1

+ 21 - 0
.dockerignore

@@ -0,0 +1,21 @@
+# Root-level .dockerignore — applies only to seq-interp (context: .)
+# All other services use their own subdirectory as context.
+#
+# Goal: include only services/seq-interp/ and libs/lf-scanner/
+
+apps/
+hardware/
+services/orchestrator/
+services/spectrometer/
+services/reconstructor/
+services/spectroscopy/
+.git/
+.gitignore
+.env
+.env.example
+*.md
+*.ps1
+*.bat
+Makefile
+storage/
+diagrams/

+ 47 - 0
.gitignore

@@ -1,4 +1,51 @@
+# env
 .env
+
+# Python
 *.pyc
+*.pyo
 __pycache__/
+*.egg-info/
+.eggs/
+
+# Virtual environments
+.venv/
+venv/
+env/
+
+# OS
 .DS_Store
+Thumbs.db
+
+# Docker volumes / service data
+storage/
+services/*/db.sqlite3
+services/spectrometer/db.sqlite3
+
+# Logs
+*.log
+services/*/log/
+
+# IDE
+.idea/
+.vscode/
+*.iml
+
+# seq-interp runtime data (kept as volumes)
+services/seq-interp/data/input/
+services/seq-interp/data/output/
+services/seq-interp/log/
+
+# spectrometer runtime artefacts
+services/spectrometer/mvenv/
+services/spectrometer/picologs/
+services/spectrometer/synclogs/
+services/spectrometer/temp/
+services/spectrometer/db.sqlite3
+
+# reconstructor build artefacts
+services/reconstructor/build/
+services/reconstructor/dist/
+services/reconstructor/*.h5
+services/reconstructor/*.mat
+

+ 51 - 15
Makefile

@@ -1,19 +1,44 @@
-# lf_mri_platform — unified microservice stack
-# Requires: Docker Desktop with Compose v2, GNU make (or nmake on Windows)
+# lf_mri_platform — unified MRI microservice stack
+# Requires: Docker Desktop with Compose v2, GNU make
 #
-# Usage:
+# Service targets:
 #   make plug          — start all services in stub mode (no hardware)
-#   make real          — start all services in real mode (live hardware)
+#   make real          — start all services in real hardware mode
 #   make down          — stop and remove containers
+#   make build         — build all images without starting
 #   make logs          — tail logs from all services
 #   make health        — check all service health endpoints
-#   make restart svc=orchestrator  — restart a single service
+#   make restart       — restart a single service: make restart svc=orchestrator
+#   make ps            — show container status
+#
+# GUI targets:
+#   make install       — install prerequisites (venv + deps for GUI)
+#   make gui           — start GUI only (no Docker services)
+#   make start         — start Docker services + GUI (plug mode)
+#   make start-real    — start Docker services + GUI (real mode)
+#
+# Dev targets:
+#   make shell         — open shell in a running container: make shell svc=orchestrator
+#   make clean         — stop + remove volumes (full reset)
 
-.PHONY: up down plug real logs health restart build ps
+.PHONY: up down plug real logs health restart build ps \
+        install gui start start-real shell clean
 
-ENV_FILE := .env
+ENV_FILE  := .env
+GUI_DIR   := apps/gui
+VENV      := $(GUI_DIR)/.venv
+PYTHON    := python
 
-# ── Startup targets ──────────────────────────────────────────────────────────
+# ── Bootstrap ────────────────────────────────────────────────────────────────
+
+$(ENV_FILE):
+	@echo "Creating .env from .env.example..."
+	cp .env.example $(ENV_FILE)
+
+install: $(ENV_FILE)
+	powershell -ExecutionPolicy Bypass -File install.ps1
+
+# ── Service startup ──────────────────────────────────────────────────────────
 
 up: $(ENV_FILE)
 	docker compose --env-file $(ENV_FILE) up --build -d
@@ -27,9 +52,23 @@ real: $(ENV_FILE)
 down:
 	docker compose down
 
-build:
+clean:
+	docker compose down --volumes
+
+build: $(ENV_FILE)
 	docker compose --env-file $(ENV_FILE) build
 
+# ── GUI ──────────────────────────────────────────────────────────────────────
+
+gui:
+	$(VENV)/Scripts/python $(GUI_DIR)/app.py
+
+start: plug
+	$(MAKE) gui
+
+start-real: real
+	$(MAKE) gui
+
 # ── Monitoring ───────────────────────────────────────────────────────────────
 
 logs:
@@ -41,6 +80,9 @@ ps:
 restart:
 	docker compose restart $(svc)
 
+shell:
+	docker compose exec $(svc) /bin/sh
+
 # ── Health checks ────────────────────────────────────────────────────────────
 
 health:
@@ -49,9 +91,3 @@ health:
 	@echo "spectrometer  :" && curl -sf http://localhost:$${SPECTROMETER_PORT:-8000}/api/    || echo "OFFLINE"
 	@echo "reconstructor :" && curl -sf http://localhost:$${RECONSTRUCTOR_PORT:-8081}/health || echo "OFFLINE"
 	@echo "spectroscopy  :" && curl -sf http://localhost:$${SPECTROSCOPY_PORT:-8002}/health  || echo "OFFLINE"
-
-# ── Bootstrap ────────────────────────────────────────────────────────────────
-
-$(ENV_FILE):
-	@echo "Creating .env from .env.example..."
-	cp .env.example $(ENV_FILE)

+ 216 - 0
apps/gui/ARCHITECTURE.md

@@ -0,0 +1,216 @@
+# LF-MRI Unified GUI — Architecture
+
+## Overview
+
+`lf_mri_gui` is a PySide6 desktop application that serves as the control frontend
+for the LF-MRI system. It is a **hybrid client**: some tabs communicate with the
+backend microservices over HTTP, others execute local Python logic.
+
+The backend services run in Docker via `lf_mri_platform`.
+
+---
+
+## Tab structure
+
+| # | Tab | Class | Communication |
+|---|-----|-------|---------------|
+| 0 | **Sequence** | `SeqInterpTab` | REST → seq-interp:7475 **if online**, otherwise local Python fallback |
+| 1 | **Scanner** | `ScannerTab` | REST → orchestrator:1717 (always) |
+| 2 | **FID** | `FidTab` | Local Python + file I/O only |
+
+---
+
+## Directory structure
+
+```
+lf_mri_gui/
+├── app.py                          # Entry point (argparse + QApplication)
+├── build_exe.bat                   # PyInstaller build wrapper
+├── lf_mri_gui.spec                 # PyInstaller spec (one-folder .exe)
+├── requirements.txt
+├── cfg/
+│   ├── hw_config.json              # HackRF, PicoScope, GRU×3, DuePP
+│   ├── server_config.json          # orchestrator_url, seq_interp_url
+│   └── updated_constraints_lf.json # LF hardware constraints preset
+└── src/
+    ├── app_window.py               # LFMRIWindow(QMainWindow) — hosts QTabWidget
+    ├── server_worker.py            # ServerWorker(QThread) — embedded uvicorn
+    ├── tabs/
+    │   ├── seq_interp_tab.py       # Tab 0 — Sequence (hybrid HTTP/local)
+    │   ├── scanner_tab.py          # Tab 1 — Scanner (orchestrator client)
+    │   └── fid_tab.py              # Tab 2 — FID (local only)
+    ├── clients/
+    │   ├── orchestrator_client.py  # httpx client for lf_orchestration :1717
+    │   └── seq_interp_client.py    # httpx client for seq-interp :7475
+    ├── core/                       # synchronizer, waveform_processor, seq_generator
+    ├── gui/                        # plot_panel, preview_panel, scheme_panel,
+    │   │                           #   controls_panel, block_table, adapters, workers
+    ├── hardware/                   # constraints.py
+    ├── interfaces/                 # pulseq_adapter, xml_generator, rf_exporter,
+    │   │                           #   gradient_exporter, picoscope_exporter,
+    │   │                           #   post_request_generator
+    └── fid/
+        └── seqgen_FID.py           # FID sequence generator (local)
+```
+
+---
+
+## Tab 0 — Sequence (`SeqInterpTab`)
+
+Full `.seq` interpretation pipeline with HTTP-first, local fallback strategy.
+
+### HTTP mode (seq-interp service online)
+1. `SeqInterpClient.healthcheck()` → `GET http://localhost:7475/health`
+2. `SeqInterpHttpWorker` uploads `.seq` via `POST /interpret/`
+3. Polls `GET /interpret/` for status
+4. Fetches result from `GET /result/{task_id}` — returns:
+   - `xml_text` — sync XML
+   - `post_json` — scanner POST payload
+   - `metadata` — block counts, total duration
+   - `waveforms` — downsampled Gx/Gy/Gz, RF amp/phase, ADC gate arrays
+5. UI populated from JSON; "Send to Scanner" button appears
+
+### Local fallback (seq-interp service offline)
+`LoadInterpWorker` runs the full pipeline in-process:
+`PulseqLoader → Synchronizer → XMLGenerator → exporters → PostRequestGenerator`
+
+### Controls
+- Load `.seq` file (button or `File > Load .seq…` / `Ctrl+O`)
+- Hardware constraint spinboxes (RF_DELAY, TR_DELAY, rasters, etc.)
+- Waveform plots: RF magnitude/IQ, Gx/Gy/Gz gradients, TTL gates (pyqtgraph)
+- Compact timeline scheme with coloured sync blocks
+- Block table — 12 columns, colour-coded by block type
+- Right panel: Block Details / Warnings / Sync XML / POST JSON / Log
+- Export: RF binary, gradient files, `sync_v2.xml`, PicoScope config
+- **"Send to Scanner"** button — appears after successful interpretation,
+  emits `ready_for_scan(info_dict)` → switches to Scanner tab
+
+---
+
+## Tab 1 — Scanner (`ScannerTab`)
+
+Exclusively communicates with `lf_orchestration` at `orchestrator_url` (default `http://localhost:1717`).
+
+### Layout
+```
+┌──────────────────────┬──────────────────────────────────────────┐
+│  Orchestrator URL    │  Steps                                   │
+│  [____________] Conn │  ┌────┬───────────────────┬──────────┐   │
+│                      │  │ #  │ Name              │ Status   │   │
+│  Scenario            │  ├────┼───────────────────┼──────────┤   │
+│  [___________▼]      │  │ 1  │ interpret_sequence│ done     │   │
+│  [Load] [Run All]    │  │ 2  │ start_measurement │ running  │   │
+│  [Next] [Abort]      │  │ 3  │ wait_data_ready   │ pending  │   │
+│                      │  └────┴───────────────────┴──────────┘   │
+│                      │  Step log                                │
+└──────────────────────┴──────────────────────────────────────────┘
+```
+
+### Workflow
+1. Enter orchestrator URL → **Connect** → `GET /scenario/list`
+2. Select scenario from dropdown
+3. **Load** → `POST /scenario/load/{name}` → receives `job_id`
+4. **Run All** → `POST /scenario/{job_id}/run_all`
+   or **Next** → `POST /scenario/{job_id}/next`
+5. QTimer polls `GET /scenario/{job_id}` every 1.5 s → updates step table
+6. Step colours: pending=grey, running=orange, done=green, failed=red
+7. **Abort** → `POST /scenario/{job_id}/abort`
+
+### Public API (called by `LFMRIWindow`)
+- `set_hw_config(path)` — propagate hw_config.json path
+- `apply_seq_info(info_dict)` — receive POST payload from Sequence tab
+
+---
+
+## Tab 2 — FID (`FidTab`)
+
+Generates FID `.seq` files locally using `src/fid/seqgen_FID.py`.
+On success emits `fid_seq_generated(path)` → `LFMRIWindow` hands the path
+to `SeqInterpTab.load_seq_file()` and switches to the Sequence tab.
+
+---
+
+## Cross-tab wiring
+
+```
+FidTab.fid_seq_generated(path)  ──→  LFMRIWindow._on_fid_generated()
+                                         ├── SeqInterpTab.load_seq_file(path)
+                                         └── tabs.setCurrentIndex(0)
+
+SeqInterpTab.ready_for_scan(info) ─→  LFMRIWindow._on_ready_for_scan()
+                                         ├── ScannerTab.apply_seq_info(info)
+                                         └── tabs.setCurrentIndex(1)
+
+File > Load .seq…               ──→  SeqInterpTab.load_seq_file(path)
+                                     tabs.setCurrentIndex(0)
+
+File > Load HW Config…          ──→  SeqInterpTab.set_hw_config(path)
+                                     ScannerTab.set_hw_config(path)
+                                     FidTab.set_hw_config(path)
+```
+
+---
+
+## Menu bar
+
+- **File**: Load .seq (`Ctrl+O`), Load HW Config, Set Output Directory,
+  Load LF Constraints, Exit (`Ctrl+Q`)
+- **Hardware**: Settings (stub), Start/Stop API Server (embedded uvicorn on :7475)
+- **Help**: About
+
+---
+
+## Embedded API server
+
+`Hardware > Start API Server` launches `ServerWorker(QThread)` which starts
+a uvicorn process serving the same `seq_interp` API on port 7475. This allows
+the GUI to act as a seq-interp service itself — useful when running without Docker.
+
+---
+
+## Backend services (Docker)
+
+Managed by `D:\Projects\lf_mri_platform\docker-compose.yml`.
+
+| Service | Port | GUI talks to it? |
+|---------|------|-----------------|
+| seq-interp | 7475 | Yes — SeqInterpTab (HTTP mode) |
+| orchestrator | 1717 | Yes — ScannerTab (always) |
+| spectrometer | 8000 | No — via orchestrator only |
+| reconstructor | 8081 | No — via orchestrator only |
+| spectroscopy | 8002 | No — standalone signal processor |
+
+---
+
+## What is not yet implemented
+
+| Tab | Feature | State |
+|-----|---------|-------|
+| Scanner | Abort job | Button exists, endpoint may not be implemented in orchestrator |
+| Scanner | Job history / past jobs | Not planned |
+| FID | Full waveform preview | Placeholder only |
+| App | Hardware Settings menu | "Not implemented" dialog |
+| App | Reconstruction results viewer | Not planned |
+
+---
+
+## Running the GUI
+
+```powershell
+# With Docker backend (recommended)
+cd D:\Projects\lf_mri_platform
+.\start.ps1          # starts Docker stack + GUI
+
+# GUI only (no Docker)
+cd D:\Projects\lf_mri\MRI-testing
+python lf_mri_gui/app.py
+
+# With a pre-loaded sequence
+python lf_mri_gui/app.py path/to/sequence.seq
+
+# Build standalone .exe
+cd D:\Projects\lf_mri\MRI-testing\lf_mri_gui
+.venv\Scripts\activate
+build_exe.bat
+# Output: dist\lf_mri_gui\lf_mri_gui.exe
+```

+ 100 - 0
apps/gui/api.py

@@ -0,0 +1,100 @@
+"""
+FastAPI application for remote MRI sequence interpretation.
+
+Endpoints:
+  POST /interpret/  — upload a .seq file, run full pipeline, return task_id
+  GET  /status/     — return status of all processed tasks
+"""
+import asyncio
+import os
+import shutil
+
+from fastapi import FastAPI, File, UploadFile
+
+from src.config import config
+from src.hardware.constraints import HardwareConstraints
+from src.interfaces.pulseq_adapter import PulseqLoader
+from src.core.synchronizer import Synchronizer
+from src.interfaces.xml_generator import XMLGenerator
+from src.interfaces.rf_exporter import RFExporter
+from src.interfaces.gradient_exporter import GradientExporter
+from src.interfaces.picoscope_exporter import PicoScopeExporter
+from src.interfaces.post_request_generator import PostRequestGenerator
+
+app = FastAPI(title="LF-MRI Sequence Interpreter API")
+
+UPLOAD_DIR = config.get("upload_dir", "uploads")
+OUTPUT_DIR = config.get("output_dir", "output")
+os.makedirs(UPLOAD_DIR, exist_ok=True)
+os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+_tasks: dict[str, str] = {}
+
+
+async def _run_pipeline(seq_path: str, task_id: str) -> None:
+    try:
+        hw = HardwareConstraints()
+        loader = PulseqLoader(hw)
+        seq_data = await asyncio.to_thread(loader.load, seq_path)
+
+        synchronizer = Synchronizer(hw)
+        sync_data = await asyncio.to_thread(synchronizer.process, seq_data["sequence"])
+
+        out_dir = os.path.join(OUTPUT_DIR, task_id)
+        os.makedirs(out_dir, exist_ok=True)
+
+        hw_cfg = config.hw_config
+
+        xml_gen = XMLGenerator()
+        adc_values, adc_starts = await asyncio.to_thread(
+            xml_gen.generate, sync_data, os.path.join(out_dir, "sync_v2.xml"), hw
+        )
+
+        tasks = [asyncio.to_thread(RFExporter().export, seq_data, seq_data.get("params", {}), out_dir)]
+        if all(k in seq_data for k in ["gx", "gy", "gz"]):
+            tasks.append(asyncio.to_thread(
+                GradientExporter().export, seq_data, seq_data.get("params", {}), out_dir
+            ))
+        iadc = hw_cfg.get("iadc", {})
+        tasks.append(asyncio.to_thread(
+            PicoScopeExporter().generate,
+            adc_values, adc_starts, out_dir, hw,
+            sampling_freq=iadc.get("srate", 8e6),
+            num_channels=iadc.get("n_channels", 3),
+        ))
+        await asyncio.gather(*tasks)
+
+        post_gen = PostRequestGenerator()
+        post_payload = post_gen.build(
+            seq_data=seq_data,
+            adc_values=adc_values,
+            sequence_path=seq_path,
+            output_dir=out_dir,
+            hw_cfg=hw_cfg,
+            rf_raster_time=seq_data.get("params", {}).get("rf_raster_time", 1e-6),
+        )
+        post_gen.write(post_payload, out_dir)
+
+        _tasks[task_id] = f"Completed → {out_dir}"
+    except Exception as exc:
+        _tasks[task_id] = f"Failed: {exc}"
+
+
+@app.post("/interpret/")
+async def interpret_endpoint(file: UploadFile = File(...)):
+    """Upload a .seq file and run the full interpretation pipeline."""
+    file_path = os.path.join(UPLOAD_DIR, file.filename)
+    with open(file_path, "wb") as buf:
+        shutil.copyfileobj(file.file, buf)
+
+    task_id = os.path.splitext(file.filename)[0]
+    _tasks[task_id] = "Processing"
+    asyncio.create_task(_run_pipeline(file_path, task_id))
+    return {"status": "accepted", "task_id": task_id,
+            "message": f"Processing {file.filename}"}
+
+
+@app.get("/status/")
+async def status_endpoint():
+    """Return the status of all submitted tasks."""
+    return {"tasks": _tasks}

+ 85 - 0
apps/gui/app.py

@@ -0,0 +1,85 @@
+"""
+Entry point for the unified LF-MRI GUI.
+
+Run from the repo root (MRI-testing/):
+    python lf_mri_gui/app.py
+
+Or with optional pre-loads:
+    python lf_mri_gui/app.py path/to/sequence.seq \\
+        --hw-config lf_mri_gui/cfg/hw_config.json \\
+        --output-dir /tmp/mri_output
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+# ── sys.path setup ────────────────────────────────────────────────────────────
+# _here     = lf_mri_gui/      → enables:  from src.xxx import ...
+# _repo_root = MRI-testing/    → enables:  from LF_scanner.pypulseq import ...
+_here = os.path.dirname(os.path.abspath(__file__))
+_repo_root = os.path.dirname(_here)
+
+for _p in (_here, _repo_root):
+    if _p not in sys.path:
+        sys.path.insert(0, _p)
+
+import argparse
+
+from PySide6.QtWidgets import QApplication
+
+from src.app_window import LFMRIWindow
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="LF-MRI System — unified scanner GUI"
+    )
+    parser.add_argument(
+        "seq_file", nargs="?", default=None,
+        help="Optional .seq file to pre-load in the Sequence tab",
+    )
+    parser.add_argument(
+        "--hw-config", default=None,
+        help="Optional hardware config JSON to load on startup",
+    )
+    parser.add_argument(
+        "--output-dir", default=None,
+        help="Optional output directory for exports",
+    )
+    args = parser.parse_args()
+
+    app = QApplication(sys.argv)
+    app.setApplicationName("LF-MRI System")
+    app.setOrganizationName("LF-MRI")
+
+    hw_config = os.path.abspath(args.hw_config)  if args.hw_config  and os.path.isfile(args.hw_config)  else None
+    output_dir = os.path.abspath(args.output_dir) if args.output_dir else None
+    seq_file  = os.path.abspath(args.seq_file)   if args.seq_file   and os.path.isfile(args.seq_file)   else None
+
+    # Load service URLs from bundled config (fallback to defaults)
+    _cfg_path = os.path.join(_here, "cfg", "server_config.json")
+    orchestrator_url = "http://localhost:1717"
+    seq_interp_url   = "http://localhost:7475"
+    try:
+        import json as _json
+        with open(_cfg_path, encoding="utf-8") as _f:
+            _cfg = _json.load(_f)
+        orchestrator_url = _cfg.get("orchestrator_url", orchestrator_url)
+        seq_interp_url   = _cfg.get("seq_interp_url",   seq_interp_url)
+    except Exception:
+        pass
+
+    win = LFMRIWindow(
+        hw_config_path=hw_config,
+        output_dir=output_dir,
+        seq_file=seq_file,
+        orchestrator_url=orchestrator_url,
+        seq_interp_url=seq_interp_url,
+    )
+    win.show()
+    sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+    main()

+ 15 - 0
apps/gui/build_exe.bat

@@ -0,0 +1,15 @@
+@echo off
+REM Build lf_mri_gui.exe with PyInstaller
+REM Run from lf_mri_gui\ with venv activated:
+REM   .venv\Scripts\activate
+REM   build_exe.bat
+
+echo [1/3] Installing PyInstaller...
+pip install pyinstaller --quiet
+
+echo [2/3] Building...
+pyinstaller lf_mri_gui.spec --clean --noconfirm
+
+echo [3/3] Done!
+echo Output: dist\lf_mri_gui\lf_mri_gui.exe
+pause

+ 46 - 0
apps/gui/cfg/hw_config.json

@@ -0,0 +1,46 @@
+{
+  "center_freq": 2950000,
+  "sample_freq": 8000000,
+  "file_path_prefix": "C:/LF_MRI/test_21_02_25/",
+  "hw_service_url": null,
+  "iadc": {
+    "device_model": "PS4000A",
+    "srate": 8000000,
+    "n_channels": 3,
+    "channel_ranges": [5, 5, 5],
+    "n_triggers": 1,
+    "averaging": 100,
+    "averaging_delay": 10,
+    "trigger_channel": 2,
+    "trig_direction": 0,
+    "threshold": 5000,
+    "auto_measure_time": 10000,
+    "enabled": true
+  },
+  "isync": {
+    "device_model": "DuePP",
+    "port": 7
+  },
+  "isdr": {
+    "device_model": "HackRF",
+    "srate": 2000000,
+    "freq": 2950000,
+    "ampl": true,
+    "gain": 15
+  },
+  "igrax": {
+    "device_model": "GRU",
+    "ip": "127.0.0.1",
+    "enabled": false
+  },
+  "igray": {
+    "device_model": "GRU",
+    "ip": "127.0.0.1",
+    "enabled": false
+  },
+  "igraz": {
+    "device_model": "GRU",
+    "ip": "127.0.0.1",
+    "enabled": false
+  }
+}

+ 10 - 0
apps/gui/cfg/server_config.json

@@ -0,0 +1,10 @@
+{
+  "srv_name" : "srv_interp",
+  "log_dir": "log",
+  "upload_dir": "data/input",
+  "output_dir": "data/output",
+  "server_host": "0.0.0.0",
+  "server_port": 7475,
+  "orchestrator_url": "http://localhost:1717",
+  "seq_interp_url": "http://localhost:7475"
+}

+ 10 - 0
apps/gui/cfg/updated_constraints_lf.json

@@ -0,0 +1,10 @@
+{
+  "RF_DELAY": 0.0005,
+  "TR_DELAY": 0.0,
+  "START_DELAY": 1.7e-05,
+  "MIN_BLOCK_DURATION": 2e-08,
+  "rf_raster_time": 1e-06,
+  "grad_raster_time": 1e-05,
+  "adc_raster_time": 1e-07,
+  "block_duration_raster": 1e-05
+}

+ 106 - 0
apps/gui/lf_mri_gui.spec

@@ -0,0 +1,106 @@
+# -*- mode: python ; coding: utf-8 -*-
+# PyInstaller spec for lf_mri_gui
+#
+# Build:  pyinstaller lf_mri_gui.spec
+# Output: dist\lf_mri_gui\lf_mri_gui.exe   (one-folder mode)
+#
+# Run from the lf_mri_gui directory with venv activated.
+
+import sys
+from pathlib import Path
+
+HERE = Path(SPECPATH)           # lf_mri_gui/
+REPO = HERE.parent              # MRI-testing/
+
+block_cipher = None
+
+a = Analysis(
+    [str(HERE / 'app.py')],
+    pathex=[str(HERE), str(REPO)],
+    binaries=[],
+    datas=[
+        # ── Config files ──────────────────────────────────────────
+        (str(HERE / 'cfg' / 'server_config.json'),  'cfg'),
+        (str(HERE / 'cfg' / 'hw_config.json'),      'cfg'),
+        # ── LF Constraints preset ─────────────────────────────────
+        (str(HERE / 'cfg' / 'updated_constraints_lf.json'), 'cfg'),
+        # ── LF_scanner / pypulseq ─────────────────────────────────
+        (str(REPO / 'LF_scanner'), 'LF_scanner'),
+    ],
+    hiddenimports=[
+        # PySide6 plugins needed at runtime
+        'PySide6.QtSvg',
+        'PySide6.QtPrintSupport',
+        # pyqtgraph internals
+        'pyqtgraph.graphicsItems.ViewBox.axisCtrlTemplate_pyqt5',
+        'pyqtgraph.graphicsItems.PlotItem.plotConfigTemplate_pyqt5',
+        'pyqtgraph.imageview.ImageViewTemplate_pyqt5',
+        # numba
+        'numba',
+        'numba.core.compiler_lock',
+        # scipy
+        'scipy._lib.messagestream',
+        # httpx
+        'httpx',
+        # yattag
+        'yattag',
+        # src modules (discovered via pathex but listed as safety net)
+        'src.app_window',
+        'src.tabs.seq_interp_tab',
+        'src.tabs.scanner_tab',
+        'src.tabs.fid_tab',
+        'src.clients.orchestrator_client',
+        'src.clients.seq_interp_client',
+        'src.gui.workers',
+        'src.core.synchronizer',
+        'src.interfaces.pulseq_adapter',
+        'src.interfaces.xml_generator',
+        'src.fid.seqgen_FID',
+    ],
+    hookspath=[],
+    hooksconfig={},
+    runtime_hooks=[],
+    excludes=[
+        # Not needed in packaged app
+        'pytest',
+        'tkinter',
+        'IPython',
+        'notebook',
+        'jupyter',
+    ],
+    win_no_prefer_redirects=False,
+    win_private_assemblies=False,
+    cipher=block_cipher,
+    noarchive=False,
+)
+
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+
+exe = EXE(
+    pyz,
+    a.scripts,
+    [],
+    exclude_binaries=True,
+    name='lf_mri_gui',
+    debug=False,
+    bootloader_ignore_signals=False,
+    strip=False,
+    upx=True,
+    console=False,            # no console window
+    disable_windowed_traceback=False,
+    target_arch=None,
+    codesign_identity=None,
+    entitlements_file=None,
+    icon=None,                # add icon=r'path\to\icon.ico' if available
+)
+
+coll = COLLECT(
+    exe,
+    a.binaries,
+    a.zipfiles,
+    a.datas,
+    strip=False,
+    upx=True,
+    upx_exclude=[],
+    name='lf_mri_gui',
+)

+ 11 - 0
apps/gui/requirements.txt

@@ -0,0 +1,11 @@
+PySide6>=6.5
+pyqtgraph>=0.13
+numpy>=1.24
+numba>=0.57
+scipy>=1.10
+yattag>=1.15
+fastapi>=0.100
+uvicorn>=0.20
+httpx>=0.24
+python-multipart>=0.0.6
+pytest>=7.0

+ 0 - 0
apps/gui/src/__init__.py


+ 434 - 0
apps/gui/src/app_window.py

@@ -0,0 +1,434 @@
+"""
+LFMRIWindow — main application window for the unified LF-MRI GUI.
+
+Single nav-bar design: tab buttons on the left, menu pop-ups on the right.
+The native QMenuBar and QTabWidget tab-bar are both hidden; a QToolBar
+provides a unified, flat navigation strip.
+"""
+from __future__ import annotations
+
+import os
+
+from PySide6.QtCore import Qt, QSize
+from PySide6.QtGui import QAction, QKeySequence
+from PySide6.QtWidgets import (
+    QApplication, QButtonGroup, QFileDialog, QFrame,
+    QLabel, QMainWindow, QMenu, QMessageBox, QPushButton,
+    QSizePolicy, QStatusBar, QTabWidget, QToolBar, QWidget,
+)
+
+from src.tabs.seq_interp_tab import SeqInterpTab
+from src.tabs.scanner_tab import ScannerTab
+from src.tabs.fid_tab import FidTab
+from src.tabs.scanning_tab import ScanningTab
+from src.server_worker import ServerWorker
+
+_TAB_NAMES = ["Sequence", "Scanner", "FID", "Scanning"]
+
+# ── nav-bar style constants ────────────────────────────────────────────────────
+_NAV_BG      = "#0f0f1e"
+_NAV_H       = 38          # pixels
+_TAB_BTN_CSS = """
+QPushButton {
+    background: transparent;
+    color: #7777aa;
+    border: none;
+    border-bottom: 2px solid transparent;
+    padding: 0px 20px;
+    font-size: 12px;
+    min-height: 36px;
+}
+QPushButton:checked {
+    color: #ffffff;
+    border-bottom: 2px solid #f0c040;
+}
+QPushButton:hover:!checked {
+    color: #aaaacc;
+    background: #17172e;
+}
+"""
+_MENU_BTN_CSS = """
+QPushButton {
+    background: transparent;
+    color: #666688;
+    border: none;
+    padding: 0px 12px;
+    font-size: 11px;
+    min-height: 36px;
+}
+QPushButton:hover  { color: #aaaacc; background: #17172e; }
+QPushButton:pressed { background: #111128; }
+QPushButton::menu-indicator { width: 0px; image: none; }
+"""
+_NAV_TOOLBAR_CSS = f"""
+QToolBar {{
+    background: {_NAV_BG};
+    border: none;
+    border-bottom: 1px solid #1e1e38;
+    spacing: 0px;
+    padding: 0px;
+}}
+"""
+
+
+class LFMRIWindow(QMainWindow):
+    """Unified LF-MRI application window."""
+
+    def __init__(
+        self,
+        hw_config_path: str | None = None,
+        output_dir: str | None = None,
+        seq_file: str | None = None,
+        orchestrator_url: str = "http://localhost:1717",
+        seq_interp_url: str = "http://localhost:7475",
+    ) -> None:
+        super().__init__()
+        self.setWindowTitle("LF-MRI System")
+        self.setMinimumSize(960, 640)
+
+        self._hw_config_path = hw_config_path
+        self._output_dir     = output_dir
+        self._server_worker: ServerWorker | None = None
+
+        # ── tabs ──────────────────────────────────────────────────────────
+        self._seq_tab      = SeqInterpTab(hw_config_path=hw_config_path,
+                                          output_dir=output_dir,
+                                          seq_interp_url=seq_interp_url)
+        self._scanner_tab  = ScannerTab(hw_config_path=hw_config_path,
+                                        orchestrator_url=orchestrator_url)
+        self._fid_tab      = FidTab(hw_config_path=hw_config_path,
+                                    output_dir=output_dir)
+        self._scanning_tab = ScanningTab()
+        self._scanning_tab.set_orchestrator_url(orchestrator_url)
+
+        self._tabs = QTabWidget()
+        self._tabs.tabBar().hide()          # driven by our custom nav bar
+        self._tabs.setDocumentMode(True)    # removes the frame / pane border
+        self._tabs.addTab(self._seq_tab,      _TAB_NAMES[0])
+        self._tabs.addTab(self._scanner_tab,  _TAB_NAMES[1])
+        self._tabs.addTab(self._fid_tab,      _TAB_NAMES[2])
+        self._tabs.addTab(self._scanning_tab, _TAB_NAMES[3])
+        self._tabs.currentChanged.connect(self._on_tab_changed)
+        self.setCentralWidget(self._tabs)
+
+        # ── cross-tab wiring ──────────────────────────────────────────────
+        self._fid_tab.fid_seq_generated.connect(self._on_fid_generated)
+        self._seq_tab.ready_for_scan.connect(self._on_ready_for_scan)
+
+        # ── unified nav bar (replaces menubar + tabbar) ───────────────────
+        self.menuBar().hide()
+        self._build_nav_bar()
+        self._build_status_bar()
+
+        # ── sizing ────────────────────────────────────────────────────────
+        self._size_and_center()
+
+        # ── pre-load from CLI ─────────────────────────────────────────────
+        if seq_file and os.path.isfile(seq_file):
+            self._seq_tab.load_seq_file(os.path.abspath(seq_file))
+
+    # ================================================================== #
+    #  Unified nav bar                                                     #
+    # ================================================================== #
+
+    def _build_nav_bar(self) -> None:
+        tb = QToolBar("Navigation", self)
+        tb.setMovable(False)
+        tb.setFloatable(False)
+        tb.setIconSize(QSize(0, 0))
+        tb.setStyleSheet(_NAV_TOOLBAR_CSS)
+        tb.setFixedHeight(_NAV_H)
+        self.addToolBar(Qt.TopToolBarArea, tb)
+
+        # ── app label ─────────────────────────────────────────────────────
+        lbl = QLabel("  LF-MRI  ")
+        lbl.setStyleSheet(f"color: #444466; font-weight: bold; font-size: 11px; "
+                          f"background: {_NAV_BG}; padding: 0 4px;")
+        tb.addWidget(lbl)
+
+        sep = _VSep(tb)
+        tb.addWidget(sep)
+
+        # ── tab navigation buttons ────────────────────────────────────────
+        self._nav_btn_group   = QButtonGroup(self)
+        self._nav_btn_group.setExclusive(True)
+        self._nav_tab_buttons: list[QPushButton] = []
+
+        for i, name in enumerate(_TAB_NAMES):
+            btn = QPushButton(name)
+            btn.setCheckable(True)
+            btn.setStyleSheet(_TAB_BTN_CSS)
+            btn.setFixedHeight(_NAV_H)
+            btn.setCursor(Qt.PointingHandCursor)
+            self._nav_btn_group.addButton(btn, i)
+            tb.addWidget(btn)
+            self._nav_tab_buttons.append(btn)
+            btn.clicked.connect(lambda _checked, idx=i: self._switch_tab(idx))
+
+        self._nav_tab_buttons[0].setChecked(True)
+
+        # ── spacer ────────────────────────────────────────────────────────
+        spacer = QWidget()
+        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
+        spacer.setStyleSheet(f"background: {_NAV_BG};")
+        tb.addWidget(spacer)
+
+        sep2 = _VSep(tb)
+        tb.addWidget(sep2)
+
+        # ── right-side menu pop-up buttons ────────────────────────────────
+        self._file_menu = self._make_file_menu()
+        self._hw_menu   = self._make_hw_menu()
+        self._help_menu = self._make_help_menu()
+
+        for label, menu in (
+            ("File",     self._file_menu),
+            ("Hardware", self._hw_menu),
+            ("Help",     self._help_menu),
+        ):
+            btn = QPushButton(label)
+            btn.setStyleSheet(_MENU_BTN_CSS)
+            btn.setFixedHeight(_NAV_H)
+            btn.setCursor(Qt.PointingHandCursor)
+            btn.setMenu(menu)
+            tb.addWidget(btn)
+
+    def _switch_tab(self, index: int) -> None:
+        self._tabs.setCurrentIndex(index)
+        # keep button group in sync if called programmatically
+        self._nav_tab_buttons[index].setChecked(True)
+
+    # ── QMenu factories ────────────────────────────────────────────────────
+
+    def _make_file_menu(self) -> QMenu:
+        m = QMenu(self)
+        m.setStyleSheet(_menu_style())
+
+        act_load_seq = QAction("Load .seq…", self)
+        act_load_seq.setShortcut(QKeySequence("Ctrl+O"))
+        act_load_seq.triggered.connect(self._menu_load_seq)
+        m.addAction(act_load_seq)
+
+        act_load_hw = QAction("Load HW Config…", self)
+        act_load_hw.triggered.connect(self._menu_load_hw_config)
+        m.addAction(act_load_hw)
+
+        act_out_dir = QAction("Set Output Directory…", self)
+        act_out_dir.triggered.connect(self._menu_set_output_dir)
+        m.addAction(act_out_dir)
+
+        act_lf = QAction("Load LF Constraints…", self)
+        act_lf.triggered.connect(self._menu_load_lf_constraints)
+        m.addAction(act_lf)
+
+        m.addSeparator()
+
+        act_exit = QAction("Exit", self)
+        act_exit.setShortcut(QKeySequence("Ctrl+Q"))
+        act_exit.triggered.connect(self.close)
+        m.addAction(act_exit)
+
+        return m
+
+    def _make_hw_menu(self) -> QMenu:
+        m = QMenu(self)
+        m.setStyleSheet(_menu_style())
+
+        act_hw_settings = QAction("Settings…", self)
+        act_hw_settings.triggered.connect(
+            lambda: QMessageBox.information(
+                self, "Hardware Settings",
+                "Hardware settings panel is not yet implemented."
+            )
+        )
+        m.addAction(act_hw_settings)
+        m.addSeparator()
+
+        self._act_server = QAction("Start API Server", self)
+        self._act_server.setCheckable(True)
+        self._act_server.toggled.connect(self._toggle_server)
+        m.addAction(self._act_server)
+
+        return m
+
+    def _make_help_menu(self) -> QMenu:
+        m = QMenu(self)
+        m.setStyleSheet(_menu_style())
+
+        act_about = QAction("About LF-MRI System", self)
+        act_about.triggered.connect(self._show_about)
+        m.addAction(act_about)
+
+        return m
+
+    # ================================================================== #
+    #  Status bar                                                          #
+    # ================================================================== #
+
+    def _build_status_bar(self) -> None:
+        sb = QStatusBar()
+        sb.setStyleSheet("QStatusBar { background: #0c0c1a; color: #555577; font-size: 11px; }")
+        self.setStatusBar(sb)
+        sb.showMessage(f"Active: {_TAB_NAMES[0]}")
+
+    # ================================================================== #
+    #  Sizing / centering                                                  #
+    # ================================================================== #
+
+    def _size_and_center(self) -> None:
+        screen = QApplication.primaryScreen()
+        if screen is not None:
+            ag = screen.availableGeometry()
+            w = min(1600, max(960,  int(ag.width()  * 0.92)))
+            h = min(940,  max(640,  int(ag.height() * 0.90)))
+            self.resize(w, h)
+            self.move(
+                ag.x() + (ag.width()  - w) // 2,
+                ag.y() + (ag.height() - h) // 2,
+            )
+        else:
+            self.resize(1440, 860)
+
+    # ================================================================== #
+    #  Tab switching                                                       #
+    # ================================================================== #
+
+    def _on_tab_changed(self, index: int) -> None:
+        name = _TAB_NAMES[index] if 0 <= index < len(_TAB_NAMES) else "—"
+        self.statusBar().showMessage(f"Active: {name}")
+        # keep nav buttons in sync when tab changed programmatically
+        if 0 <= index < len(self._nav_tab_buttons):
+            self._nav_tab_buttons[index].setChecked(True)
+
+    # ================================================================== #
+    #  Cross-tab wiring                                                    #
+    # ================================================================== #
+
+    def _on_fid_generated(self, path: str) -> None:
+        self._seq_tab.load_seq_file(path)
+        self._switch_tab(0)
+
+    def _on_ready_for_scan(self, info: dict) -> None:
+        self._scanner_tab.apply_seq_info(info)
+        self._scanning_tab.apply_seq_info(info)
+        self._switch_tab(1)
+
+    # ================================================================== #
+    #  Menu slots                                                          #
+    # ================================================================== #
+
+    def _menu_load_seq(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Open Pulseq sequence", "",
+            "Pulseq files (*.seq);;All files (*)"
+        )
+        if path:
+            self._seq_tab.load_seq_file(os.path.abspath(path))
+            self._switch_tab(0)
+
+    def _menu_load_hw_config(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Open HW config", "",
+            "JSON files (*.json);;All files (*)"
+        )
+        if not path:
+            return
+        path = os.path.abspath(path)
+        self._hw_config_path = path
+        self._seq_tab.set_hw_config(path)
+        self._scanner_tab.set_hw_config(path)
+        self._fid_tab.set_hw_config(path)
+        self.statusBar().showMessage(f"HW config loaded: {os.path.basename(path)}", 4000)
+
+    def _menu_load_lf_constraints(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Open LF Constraints", "",
+            "JSON files (*.json);;All files (*)"
+        )
+        if not path:
+            return
+        path = os.path.abspath(path)
+        self._seq_tab.set_hw_config(path)
+        self._fid_tab.set_hw_config(path)
+        self.statusBar().showMessage(
+            f"LF constraints loaded: {os.path.basename(path)}", 4000
+        )
+
+    def _menu_set_output_dir(self) -> None:
+        path = QFileDialog.getExistingDirectory(self, "Choose output directory")
+        if path:
+            self._output_dir = path
+            self._seq_tab.set_output_dir(path)
+            self._fid_tab.set_output_dir(path)
+            self.statusBar().showMessage(f"Output dir: {path}", 4000)
+
+    def _toggle_server(self, checked: bool) -> None:
+        if checked:
+            self._server_worker = ServerWorker()
+            self._server_worker.started_ok.connect(self._on_server_started)
+            self._server_worker.error.connect(self._on_server_error)
+            self._server_worker.finished.connect(
+                lambda: self._act_server.setChecked(False)
+            )
+            self._server_worker.start()
+            self._act_server.setText("Stop API Server")
+        else:
+            if self._server_worker is not None:
+                self._server_worker.stop()
+                self._server_worker = None
+            self._act_server.setText("Start API Server")
+            self.statusBar().showMessage("API server stopped", 3000)
+
+    def _on_server_started(self, url: str) -> None:
+        self.statusBar().showMessage(f"API server running at {url}", 0)
+
+    def _on_server_error(self, msg: str) -> None:
+        self._act_server.setChecked(False)
+        self._act_server.setText("Start API Server")
+        QMessageBox.critical(self, "Server error", msg)
+
+    # ================================================================== #
+    #  About dialog                                                        #
+    # ================================================================== #
+
+    def _show_about(self) -> None:
+        QMessageBox.about(
+            self,
+            "About LF-MRI System",
+            "<b>LF-MRI System</b><br>"
+            "Unified GUI for low-field MRI spectrometer control.<br><br>"
+            "Tabs:<br>"
+            "&nbsp;&nbsp;<b>Sequence</b> — Pulseq interpreter, waveform viewer, export<br>"
+            "&nbsp;&nbsp;<b>Scanner</b> — Hardware connection &amp; acquisition control<br>"
+            "&nbsp;&nbsp;<b>FID</b> — FID sequence generator and visualiser<br>"
+            "&nbsp;&nbsp;<b>Scanning</b> — Clinical MRI viewer (scanning simulation)<br><br>"
+            "Hardware → Start API Server exposes REST endpoints on port 7475.<br><br>"
+            "Built with PySide6 · pyqtgraph · pypulseq",
+        )
+
+
+# ── helpers ────────────────────────────────────────────────────────────────────
+
+class _VSep(QFrame):
+    """Thin vertical separator for the nav toolbar."""
+    def __init__(self, parent=None) -> None:
+        super().__init__(parent)
+        self.setFrameShape(QFrame.VLine)
+        self.setFixedWidth(1)
+        self.setStyleSheet("background: #1e1e38; border: none;")
+
+
+def _menu_style() -> str:
+    return """
+        QMenu {
+            background: #12122a;
+            color: #ccccee;
+            border: 1px solid #2a2a4a;
+            padding: 4px 0;
+            font-size: 12px;
+        }
+        QMenu::item { padding: 6px 24px 6px 16px; }
+        QMenu::item:selected { background: #1e1e48; color: #ffffff; }
+        QMenu::item:disabled { color: #555577; }
+        QMenu::separator { height: 1px; background: #2a2a4a; margin: 3px 8px; }
+    """

+ 0 - 0
apps/gui/src/clients/__init__.py


+ 95 - 0
apps/gui/src/clients/orchestrator_client.py

@@ -0,0 +1,95 @@
+"""
+Synchronous HTTP client for the lf_orchestration service.
+
+All methods are blocking and intended to be called from a QThread worker,
+never from the Qt UI thread.
+"""
+from __future__ import annotations
+
+
+class OrchestratorError(Exception):
+    def __init__(self, message: str, status_code: int | None = None) -> None:
+        super().__init__(message)
+        self.status_code = status_code
+
+
+class OrchestratorClient:
+    """Thin wrapper around the orchestrator REST API (port 1717 by default)."""
+
+    def __init__(self, base_url: str = "http://localhost:1717") -> None:
+        self.base_url = base_url.rstrip("/")
+
+    # ── internals ─────────────────────────────────────────────────────────
+
+    def _get(self, path: str) -> dict:
+        import httpx
+        try:
+            r = httpx.get(
+                f"{self.base_url}{path}",
+                timeout=httpx.Timeout(connect=3.0, read=120.0, write=10.0, pool=5.0),
+            )
+        except httpx.ConnectError as exc:
+            raise OrchestratorError(f"Cannot connect to orchestrator at {self.base_url}") from exc
+        except httpx.TimeoutException as exc:
+            raise OrchestratorError("Orchestrator request timed out") from exc
+        if not r.is_success:
+            raise OrchestratorError(r.text, status_code=r.status_code)
+        return r.json()
+
+    def _post(self, path: str, json: dict | None = None) -> dict:
+        import httpx
+        try:
+            r = httpx.post(
+                f"{self.base_url}{path}",
+                json=json,
+                timeout=httpx.Timeout(connect=3.0, read=120.0, write=10.0, pool=5.0),
+            )
+        except httpx.ConnectError as exc:
+            raise OrchestratorError(f"Cannot connect to orchestrator at {self.base_url}") from exc
+        except httpx.TimeoutException as exc:
+            raise OrchestratorError("Orchestrator request timed out") from exc
+        if not r.is_success:
+            raise OrchestratorError(r.text, status_code=r.status_code)
+        return r.json()
+
+    # ── public API ─────────────────────────────────────────────────────────
+
+    def healthcheck(self) -> bool:
+        """Return True if the orchestrator responds on /scenario/list."""
+        try:
+            self._get("/scenario/list")
+            return True
+        except OrchestratorError:
+            return False
+
+    def list_scenarios(self) -> list[str]:
+        """Return list of available scenario IDs."""
+        data = self._get("/scenario/list")
+        return data.get("scenarios", [])
+
+    def load_scenario(
+        self,
+        scenario_id: str,
+        param_overrides: dict | None = None,
+    ) -> str:
+        """
+        Load a scenario template and return the job_id.
+
+        param_overrides: optional dict {step_name: {param_key: value}}
+        e.g. {"start_measurement": {"info": {...}}}
+        """
+        body = {"param_overrides": param_overrides} if param_overrides else None
+        data = self._post(f"/scenario/load/{scenario_id}", json=body)
+        return data["job_id"]
+
+    def run_all(self, job_id: str) -> dict:
+        """Execute all remaining steps and return the final step list."""
+        return self._post(f"/scenario/{job_id}/run_all")
+
+    def next_step(self, job_id: str) -> dict:
+        """Execute the next single step and return its result."""
+        return self._post(f"/scenario/{job_id}/next")
+
+    def get_status(self, job_id: str) -> dict:
+        """Return the current scenario state (steps + their statuses)."""
+        return self._get(f"/scenario/{job_id}")

+ 123 - 0
apps/gui/src/clients/seq_interp_client.py

@@ -0,0 +1,123 @@
+"""
+Synchronous HTTP client for the seq_interp service (port 7475 by default).
+
+All methods are blocking and intended to be called from a QThread worker.
+"""
+from __future__ import annotations
+
+import os
+
+
+class SeqInterpError(Exception):
+    def __init__(self, message: str, status_code: int | None = None) -> None:
+        super().__init__(message)
+        self.status_code = status_code
+
+
+class SeqInterpClient:
+    """Thin wrapper around the seq_interp REST API."""
+
+    def __init__(self, base_url: str = "http://localhost:7475") -> None:
+        self.base_url = base_url.rstrip("/")
+
+    # ── internals ─────────────────────────────────────────────────────────
+
+    def _get(self, path: str) -> dict:
+        import httpx
+        try:
+            r = httpx.get(
+                f"{self.base_url}{path}",
+                timeout=httpx.Timeout(connect=3.0, read=120.0, write=10.0, pool=5.0),
+            )
+        except httpx.ConnectError as exc:
+            raise SeqInterpError(
+                f"Cannot connect to seq_interp at {self.base_url}"
+            ) from exc
+        except httpx.TimeoutException as exc:
+            raise SeqInterpError("seq_interp request timed out") from exc
+        if not r.is_success:
+            raise SeqInterpError(r.text, status_code=r.status_code)
+        return r.json()
+
+    def _post_file(self, path: str, file_path: str) -> dict:
+        import httpx
+        filename = os.path.basename(file_path)
+        try:
+            with open(file_path, "rb") as fh:
+                r = httpx.post(
+                    f"{self.base_url}{path}",
+                    files={"file": (filename, fh, "application/octet-stream")},
+                    timeout=httpx.Timeout(connect=3.0, read=120.0, write=30.0, pool=5.0),
+                )
+        except httpx.ConnectError as exc:
+            raise SeqInterpError(
+                f"Cannot connect to seq_interp at {self.base_url}"
+            ) from exc
+        except httpx.TimeoutException as exc:
+            raise SeqInterpError("seq_interp upload timed out") from exc
+        if not r.is_success:
+            raise SeqInterpError(r.text, status_code=r.status_code)
+        return r.json()
+
+    # ── public API ────────────────────────────────────────────────────────
+
+    def healthcheck(self) -> bool:
+        """Return True if the service responds on /health."""
+        try:
+            self._get("/health")
+            return True
+        except SeqInterpError:
+            return False
+
+    def interpret(self, seq_file_path: str) -> str:
+        """Upload a .seq file and start interpretation. Returns task_id."""
+        data = self._post_file("/interpret/", seq_file_path)
+        return data["task_id"]
+
+    def get_status(self) -> dict:
+        """Return status dict for all submitted tasks."""
+        return self._get("/status/")
+
+    def get_result(self, task_id: str) -> dict:
+        """
+        Return the full interpretation result for task_id.
+        Raises SeqInterpError(status_code=202) if still processing.
+        Raises SeqInterpError(status_code=404) if task not found.
+        """
+        return self._get(f"/result/{task_id}")
+
+    def interpret_and_wait(
+        self,
+        seq_file_path: str,
+        poll_interval: float = 1.0,
+        max_attempts: int = 120,
+        progress_cb=None,
+    ) -> dict:
+        """
+        Upload .seq, poll until done, return result dict.
+
+        progress_cb: optional callable(str) for log messages.
+        """
+        import time
+
+        def _log(msg: str) -> None:
+            if progress_cb:
+                progress_cb(msg)
+
+        _log(f"Uploading {os.path.basename(seq_file_path)}…")
+        task_id = self.interpret(seq_file_path)
+        _log(f"Task accepted: {task_id}")
+
+        for attempt in range(max_attempts):
+            time.sleep(poll_interval)
+            status = self.get_status().get("tasks", {}).get(task_id, "unknown")
+            _log(f"Status: {status}")
+            if status == "completed":
+                _log("Fetching result…")
+                return self.get_result(task_id)
+            if status.startswith("failed"):
+                raise SeqInterpError(f"Interpretation failed: {status}")
+
+        raise SeqInterpError(
+            f"Interpretation timed out after {max_attempts * poll_interval:.0f}s"
+        )

+ 37 - 0
apps/gui/src/config.py

@@ -0,0 +1,37 @@
+import json
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parents[1]
+SERVER_CONFIG_PATH = str(BASE_DIR / "cfg" / "server_config.json")
+HARDWARE_CONFIG_PATH = str(BASE_DIR / "cfg" / "hw_config.json")
+
+
+class Config:
+    def __init__(self, server_config_path=SERVER_CONFIG_PATH, hw_config_path=HARDWARE_CONFIG_PATH):
+        self.server_config_path = server_config_path
+        self.hardware_config_path = hw_config_path
+        self._load_config()
+
+    def _load_config(self):
+        """Загружает конфигурацию из JSON-файлов"""
+        if not os.path.exists(self.server_config_path):
+            raise FileNotFoundError(f"Файл конфигурации сервера {self.server_config_path} не найден!")
+        if not os.path.exists(self.hardware_config_path):
+            raise FileNotFoundError(f"Файл конфигурации томографа {self.hardware_config_path} не найден!")
+
+        with open(self.server_config_path, "r") as f:
+            self.config = json.load(f)
+        with open(self.hardware_config_path, "r") as f:
+            self.hw_config = json.load(f)
+
+    def get(self, key, default=None):
+        """Получить значение из server-конфига с безопасным доступом"""
+        return self.config.get(key, default)
+
+    def get_hw(self, key, default=None):
+        """Получить значение из hw-конфига с безопасным доступом"""
+        return self.hw_config.get(key, default)
+
+
+config = Config()

+ 0 - 0
apps/gui/src/core/__init__.py


+ 27 - 0
apps/gui/src/core/sequence_generator.py

@@ -0,0 +1,27 @@
+from src.hardware.constraints import HardwareConstraints
+import numpy as np
+
+
+class SequenceGenerator:
+    def __init__(self, hw: HardwareConstraints):
+        self.hw = hw
+
+    def generate_gre(self, params: dict) -> dict:
+        """Генерация последовательности Gradient Echo."""
+        t_grad = max(self.hw.MIN_BLOCK_DURATION, params.get("t_grad", 10e-3))
+
+        # Заглушка для примера - должна быть заменена реальной генерацией
+        dummy_grad = np.linspace(-self.hw.GRAD_MAX, self.hw.GRAD_MAX, 128)
+
+        return {
+            "gradients": {
+                'gx': dummy_grad,
+                'gy': np.zeros_like(dummy_grad),
+                'gz': np.zeros_like(dummy_grad),
+                't_gx': np.linspace(0, t_grad, 128),
+                't_gy': np.array([0, t_grad]),
+                't_gz': np.array([0])
+            },
+            "rf": np.zeros(128, dtype=np.complex64),
+            "adc": np.zeros(64, dtype=np.float32)
+        }

+ 76 - 0
apps/gui/src/core/synchronizer.py

@@ -0,0 +1,76 @@
+from src.hardware.constraints import HardwareConstraints
+
+
+class Synchronizer:
+    def __init__(self, hw: HardwareConstraints):
+        self.hw = hw
+
+    def process(self, sync_sequence):
+        """
+        Повторяет логику synchronization(...) из test1_full/srv_interp.py
+        и возвращает все массивы, нужные для экспорта XML и PicoScope.
+        """
+        synchro_block_timer = self.hw.MIN_BLOCK_DURATION
+        tr_delay = self.hw.TR_DELAY
+        rf_delay = self.hw.RF_DELAY
+        start_delay = max(self.hw.START_DELAY, self.hw.RF_DELAY)
+
+        min_block_time = 800e-9
+
+        if tr_delay < synchro_block_timer:
+            tr_delay = synchro_block_timer
+        if rf_delay < synchro_block_timer:
+            rf_delay = synchro_block_timer
+
+        number_of_blocks = len(sync_sequence.block_events)
+        gate_adc = [0]
+        gate_rf = [0]
+        gate_tr_switch = [1]
+        blocks_duration = [start_delay]
+
+        added_blocks = 0
+        for block_counter in range(number_of_blocks):
+            is_not_adc_block = True
+
+            if sync_sequence.block_events[block_counter + 1][5]:
+                is_not_adc_block = False
+
+                gate_adc.append(0)
+                gate_rf.append(gate_rf[-1])
+                blocks_duration[-1] -= tr_delay
+                blocks_duration.append(tr_delay)
+                gate_tr_switch.append(0)
+                added_blocks += 1
+
+                gate_adc.append(1)
+                gate_tr_switch.append(0)
+            else:
+                gate_tr_switch.append(1)
+                gate_adc.append(0)
+
+            if sync_sequence.block_events[block_counter + 1][1] and is_not_adc_block:
+                gate_rf.append(1)
+                gate_adc.append(gate_adc[-1])
+                blocks_duration[-1] -= rf_delay
+                blocks_duration.append(rf_delay)
+                gate_tr_switch.append(gate_tr_switch[-1])
+                added_blocks += 1
+
+                gate_rf.append(1)
+            else:
+                gate_rf.append(0)
+
+            current_block_dur = sync_sequence.block_durations[block_counter + 1]
+            blocks_duration.append(current_block_dur)
+
+        number_of_blocks += added_blocks
+
+        return {
+            "number_of_blocks": number_of_blocks,
+            "gate_adc": gate_adc,
+            "gate_rf": gate_rf,
+            "gate_tr_switch": gate_tr_switch,
+            "blocks_duration": blocks_duration,
+            "synchro_block_timer": synchro_block_timer,
+            "min_block_time": min_block_time,
+        }

+ 29 - 0
apps/gui/src/core/waveform_processor.py

@@ -0,0 +1,29 @@
+import numpy as np
+from numba import njit
+
+class WaveformProcessor:
+    """
+    Обработка форм сигналов (RF, градиенты, ADC) с использованием Numba для ускорения вычислений.
+    """
+    def __init__(self, hw):
+        self.hw = hw
+
+    @staticmethod
+    @njit(fastmath=True)
+    def process_rf_numba(rf_signal: np.ndarray, max_ampl: int) -> np.ndarray:
+        """Numba-оптимизированная обработка RF-сигнала: масштабирование и квантование под диапазон int8."""
+        # Умножение на максимальную амплитуду и округление до ближайшего целого
+        return np.round(rf_signal * max_ampl).astype(np.int8)
+
+    @staticmethod
+    @njit(fastmath=True)
+    def process_gradient_numba(grad_signal: np.ndarray, max_val: int) -> np.ndarray:
+        """Numba-оптимизированная обработка градиентного сигнала: масштабирование под диапазон int16."""
+        return np.round(grad_signal * max_val).astype(np.int16)
+
+    def preprocess_adc(self, adc_signal: np.ndarray) -> np.ndarray:
+        """
+        Подготовка данных АЦП для спектрометра:
+        приведение к типу float32 и обеспечение смежности в памяти.
+        """
+        return np.ascontiguousarray(adc_signal, dtype=np.float32)

+ 0 - 0
apps/gui/src/fid/__init__.py


+ 62 - 0
apps/gui/src/fid/seqgen_FID.py

@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+import numpy as np
+
+try:
+    from LF_scanner import pypulseq as pp
+except ImportError:
+    import pypulseq as pp
+
+
+def seqgen_FID(param):
+    scanner_parameters = pp.Opts(
+        max_grad=param.G_amp_max,
+        grad_unit='Hz/m',
+        max_slew=param.G_slew_max,
+        slew_unit='Hz/m/s',
+        grad_raster_time=param.grad_raster_time,
+        rf_raster_time=param.rf_raster_time,
+        adc_raster_time=1 / (param.BW_per_point * param.N_point),
+        block_duration_raster=max(param.grad_raster_time, param.rf_raster_time),
+        rf_dead_time=param.rf_dead_time,
+        rf_ringdown_time=param.rf_ringdown_time,
+        adc_dead_time=param.adc_dead_time,
+    )
+
+    if param.is_sinc_pulse:
+        exc_pulse = pp.make_sinc_pulse(
+            flip_angle=param.flip_angle / 180 * np.pi,
+            duration=float(param.t_ex),
+            apodization=param.apodization,
+            freq_offset=param.freq_offset,
+            phase_offset=0,
+            time_bw_product=param.t_BW_product_ex,
+            system=scanner_parameters,
+        )
+    else:
+        exc_pulse = pp.make_block_pulse(
+            flip_angle=param.flip_angle / 180 * np.pi,
+            system=scanner_parameters,
+            duration=float(param.t_ex),
+        )
+
+    adc_module = pp.make_adc(
+        num_samples=int(param.N_point),
+        duration=1 / param.BW_per_point,
+        system=scanner_parameters,
+    )
+    adc_module.phase_offset = 0
+
+    tr_delay = param.TR - pp.calc_duration(exc_pulse) - pp.calc_duration(adc_module)
+    tr_delay = np.ceil(tr_delay / param.grad_raster_time) * param.grad_raster_time
+    tr_delay = pp.make_delay(tr_delay)
+
+    seq = pp.Sequence(system=scanner_parameters)
+
+    for _ in range(int(param.average) - 1):
+        seq.add_block(exc_pulse)
+        seq.add_block(adc_module)
+        seq.add_block(tr_delay)
+    seq.add_block(exc_pulse)
+    seq.add_block(adc_module)
+
+    return seq

+ 0 - 0
apps/gui/src/gui/__init__.py


+ 210 - 0
apps/gui/src/gui/adapters.py

@@ -0,0 +1,210 @@
+"""
+Data conversion helpers: seq_data / sync_data → GUI-friendly structures.
+No Qt imports — safe to import in tests.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import List, Optional
+
+import numpy as np
+
+
+@dataclass
+class BlockRow:
+    sync_index: int
+    orig_index: int          # -1 for inserted delay blocks
+    duration: float          # seconds
+    block_type: str
+    has_rf: bool
+    has_adc: bool
+    has_grad: bool
+    is_delay: bool
+    delay_type: str          # "START" | "RF" | "TR" | ""
+    gate_rf: int
+    gate_adc: int
+    gate_tr: int
+    t_start: float           # cumulative start time (s)
+    t_end: float             # cumulative end time (s)
+
+
+def build_block_rows(seq_data: dict, sync_data: dict) -> List[BlockRow]:
+    """
+    Reconstruct the mapping between sync blocks and original sequence blocks.
+
+    The Synchronizer inserts:
+      - one START_DELAY block at position 0
+      - one TR_DELAY block before each ADC block
+      - one RF_DELAY block before each RF (non-ADC) block
+
+    Returns one BlockRow per entry in sync_data["blocks_duration"].
+    """
+    blocks = seq_data.get("blocks", [])
+    gate_adc = sync_data.get("gate_adc", [])
+    gate_rf = sync_data.get("gate_rf", [])
+    gate_tr = sync_data.get("gate_tr_switch", [])
+    durs = sync_data.get("blocks_duration", [])
+
+    if not durs:
+        return []
+
+    cumtimes = np.cumsum([0.0] + list(durs))
+
+    def _row(si: int, oi: int, btype: str,
+             has_rf=False, has_adc=False, has_grad=False,
+             is_delay=False, delay_type="") -> BlockRow:
+        return BlockRow(
+            sync_index=si,
+            orig_index=oi,
+            duration=float(durs[si]) if si < len(durs) else 0.0,
+            block_type=btype,
+            has_rf=has_rf,
+            has_adc=has_adc,
+            has_grad=has_grad,
+            is_delay=is_delay,
+            delay_type=delay_type,
+            gate_rf=int(gate_rf[si]) if si < len(gate_rf) else 0,
+            gate_adc=int(gate_adc[si]) if si < len(gate_adc) else 0,
+            gate_tr=int(gate_tr[si]) if si < len(gate_tr) else 0,
+            t_start=float(cumtimes[si]),
+            t_end=float(cumtimes[si + 1]) if si + 1 < len(cumtimes) else float(cumtimes[-1]),
+        )
+
+    rows: List[BlockRow] = []
+
+    # Index 0 is always the START_DELAY block
+    r = _row(0, -1, "START_DELAY", is_delay=True, delay_type="START")
+    rows.append(r)
+
+    si = 1
+    for oi, blk in enumerate(blocks):
+        if si >= len(durs):
+            break
+
+        has_adc = blk.get("has_adc", False)
+        has_rf = "RF" in blk.get("type", [])
+        has_grad = "GRAD" in blk.get("type", [])
+
+        # Inserted delay before this block
+        if has_adc:
+            rows.append(_row(si, oi, "TR_DELAY", is_delay=True, delay_type="TR"))
+            si += 1
+        elif has_rf:
+            rows.append(_row(si, oi, "RF_DELAY", is_delay=True, delay_type="RF"))
+            si += 1
+
+        if si >= len(durs):
+            break
+
+        btype = ", ".join(blk.get("type", [])) or "DELAY"
+        rows.append(_row(si, oi, btype,
+                         has_rf=has_rf, has_adc=has_adc, has_grad=has_grad))
+        si += 1
+
+    return rows
+
+
+def gate_to_step(gate: list, durs: list):
+    """
+    Convert gate signal + block durations to (t, v) arrays for step-function plot.
+    Returns two numpy arrays of shape (2*n,).
+    """
+    n = min(len(gate), len(durs))
+    cumtimes = np.cumsum([0.0] + list(durs[:n]))
+    t = np.empty(n * 2)
+    v = np.empty(n * 2, dtype=float)
+    for i in range(n):
+        t[2 * i] = cumtimes[i]
+        t[2 * i + 1] = cumtimes[i + 1]
+        v[2 * i] = gate[i]
+        v[2 * i + 1] = gate[i]
+    return t, v
+
+
+def block_cumtimes(durs: list) -> np.ndarray:
+    """Cumulative time boundaries from blocks_duration list."""
+    return np.cumsum([0.0] + list(durs))
+
+
+def find_block_at_time(t: float, block_rows: List[BlockRow]) -> Optional[BlockRow]:
+    """Return the BlockRow whose [t_start, t_end) interval contains t."""
+    for row in block_rows:
+        if row.t_start <= t < row.t_end:
+            return row
+    return None
+
+
+def validate_timing(hw, seq_data: dict, sync_data: dict) -> List[str]:
+    """Return a list of human-readable warning strings."""
+    warnings: List[str] = []
+    mbd = hw.MIN_BLOCK_DURATION
+
+    for name, val in [("RF_DELAY", hw.RF_DELAY),
+                      ("TR_DELAY", hw.TR_DELAY),
+                      ("START_DELAY", hw.START_DELAY)]:
+        if val < mbd:
+            warnings.append(
+                f"{name} ({val * 1e9:.1f} ns) < MIN_BLOCK_DURATION ({mbd * 1e9:.1f} ns)"
+            )
+
+    timer = sync_data.get("synchro_block_timer", mbd)
+    for i, dur in enumerate(sync_data.get("blocks_duration", [])):
+        if dur > 0 and timer > 0:
+            cl = dur / timer
+            if int(cl) == 0:
+                warnings.append(
+                    f"Sync block {i}: CL rounds to zero (dur={dur * 1e9:.1f} ns)"
+                )
+
+    # Raster alignment — warn once if any block violates
+    raster = hw.block_duration_raster
+    if raster > 0:
+        for i, dur in enumerate(sync_data.get("blocks_duration", [])):
+            if dur > 0:
+                rem = dur % raster
+                if 1e-15 < rem < raster - 1e-15:
+                    warnings.append(
+                        f"Block {i}: duration {dur * 1e6:.3f} µs not aligned to "
+                        f"block_duration_raster {raster * 1e6:.3f} µs (first occurrence)"
+                    )
+                    break
+
+    for key in ["rf", "t_rf"]:
+        if key not in seq_data:
+            warnings.append(f"Waveform '{key}' not found in sequence data")
+
+    if "rf" in seq_data and "t_rf" in seq_data:
+        if len(seq_data["rf"]) != len(seq_data["t_rf"]):
+            warnings.append(
+                f"RF length mismatch: rf={len(seq_data['rf'])}, t_rf={len(seq_data['t_rf'])}"
+            )
+
+    for axis in ["gx", "gy", "gz"]:
+        t_key = f"t_{axis}"
+        if axis in seq_data and t_key in seq_data:
+            if len(seq_data[axis]) != len(seq_data[t_key]):
+                warnings.append(f"{axis.upper()} length mismatch")
+
+    return warnings
+
+
+def seq_metadata(seq_data: dict, hw) -> dict:
+    """Return a flat dict of human-readable sequence metadata."""
+    blocks = seq_data.get("blocks", [])
+    params = seq_data.get("params", {})
+    return {
+        "Total blocks (orig)": len(blocks),
+        "RF blocks": sum(1 for b in blocks if "RF" in b.get("type", [])),
+        "ADC blocks": sum(1 for b in blocks if b.get("has_adc", False)),
+        "Grad blocks": sum(1 for b in blocks if "GRAD" in b.get("type", [])),
+        "RF raster (µs)": f"{hw.rf_raster_time * 1e6:.4f}",
+        "Grad raster (µs)": f"{hw.grad_raster_time * 1e6:.4f}",
+        "ADC raster (ns)": f"{hw.adc_raster_time * 1e9:.4f}",
+        "Block raster (µs)": f"{hw.block_duration_raster * 1e6:.4f}",
+        "RF delay (ns)": f"{hw.RF_DELAY * 1e9:.1f}",
+        "TR delay (ns)": f"{hw.TR_DELAY * 1e9:.1f}",
+        "Start delay (µs)": f"{hw.START_DELAY * 1e6:.4f}",
+        "Min block dur (ns)": f"{hw.MIN_BLOCK_DURATION * 1e9:.1f}",
+        "Gamma (MHz/T)": f"{hw.gamma / 1e6:.4f}",
+        "RF scale": f"{params.get('scale_rf', 1.0):.4f}",
+    }

+ 150 - 0
apps/gui/src/gui/block_table.py

@@ -0,0 +1,150 @@
+"""
+Block table widget — one row per sync block, colour-coded by type.
+Row tints use alpha-transparent QColor values so they blend with whatever
+base colour the OS palette provides, keeping type indication readable on
+both light and dark themes.
+"""
+from __future__ import annotations
+
+from PySide6.QtCore import Signal, Qt
+from PySide6.QtWidgets import (
+    QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
+)
+from PySide6.QtGui import QColor, QBrush
+
+from src.gui.adapters import BlockRow
+
+_COL_NAMES = [
+    "#sync", "orig#", "Type", "Dur (µs)", "RF", "ADC", "Grad",
+    "Delay", "gRF", "gADC", "gTR", "T-start (µs)",
+]
+
+# Row background tints — alpha ~80/255 so they blend with the OS base colour.
+# On a white base: soft pastels.  On a dark base: subtle dark tints.
+_BG: dict[str, QColor] = {
+    "START": QColor(244, 143, 177,  80),  # pink  — start delay
+    "TR":    QColor(144, 202, 249,  80),  # blue  — TR delay
+    "RF":    QColor(255, 224, 130,  80),  # amber — RF delay
+    "adc":   QColor(165, 214, 167,  80),  # green — ADC block
+    "rf":    QColor(244, 143, 177,  80),  # pink  — RF block
+    "grad":  QColor(206, 147, 216,  80),  # purple — gradient block
+    "plain": QColor(0,   0,   0,    0 ),  # transparent — no tint
+}
+
+# Structural stylesheet only — base text/background come from the OS palette.
+# Only the selection highlight and hover state are pinned to explicit colours
+# because they need to be clearly visible on any background.
+_STYLESHEET = """
+QTableWidget {
+    gridline-color: palette(mid);
+    font-size: 12px;
+}
+QTableWidget::item {
+    padding: 2px 4px;
+}
+QTableWidget::item:selected {
+    background-color: #1565c0;
+    color: #ffffff;
+}
+QTableWidget::item:hover:!selected {
+    background-color: rgba(144, 202, 249, 60);
+}
+QHeaderView::section {
+    border: none;
+    border-bottom: 1px solid palette(mid);
+    border-right:  1px solid palette(mid);
+    padding: 4px 6px;
+    font-weight: bold;
+}
+QScrollBar:horizontal, QScrollBar:vertical {
+    background: palette(alternateBase);
+}
+"""
+
+
+def _bg_for(row: BlockRow) -> QColor:
+    if row.is_delay:
+        return _BG.get(row.delay_type, _BG["plain"])
+    if row.has_adc:
+        return _BG["adc"]
+    if row.has_rf:
+        return _BG["rf"]
+    if row.has_grad:
+        return _BG["grad"]
+    return _BG["plain"]
+
+
+class BlockTable(QTableWidget):
+    blockSelected = Signal(int)   # sync_index
+
+    def __init__(self, parent=None):
+        super().__init__(0, len(_COL_NAMES), parent)
+        self.setHorizontalHeaderLabels(_COL_NAMES)
+        self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
+        self.setSelectionBehavior(QAbstractItemView.SelectRows)
+        self.setEditTriggers(QAbstractItemView.NoEditTriggers)
+        self.setAlternatingRowColors(False)
+        self.verticalHeader().setDefaultSectionSize(20)
+        self.verticalHeader().setVisible(False)
+        self.setStyleSheet(_STYLESHEET)
+        self._rows: list[BlockRow] = []
+        self._suppress = False
+        self.itemSelectionChanged.connect(self._on_selection)
+
+    # ── public ────────────────────────────────────────────────────────────────
+
+    def load_rows(self, rows: list[BlockRow]) -> None:
+        self._rows = rows
+        self.setRowCount(0)
+        self.setRowCount(len(rows))
+        for r, row in enumerate(rows):
+            bg = QBrush(_bg_for(row))
+            vals = [
+                str(row.sync_index),
+                str(row.orig_index) if row.orig_index >= 0 else "—",
+                row.block_type,
+                f"{row.duration * 1e6:.3f}",
+                "✓" if row.has_rf else "",
+                "✓" if row.has_adc else "",
+                "✓" if row.has_grad else "",
+                row.delay_type if row.is_delay else "",
+                str(row.gate_rf),
+                str(row.gate_adc),
+                str(row.gate_tr),
+                f"{row.t_start * 1e6:.3f}",
+            ]
+            for c, val in enumerate(vals):
+                item = QTableWidgetItem(val)
+                item.setTextAlignment(Qt.AlignCenter)
+                item.setBackground(bg)
+                # Foreground intentionally not set: the OS palette Text role
+                # provides the correct readable colour for the current theme.
+                self.setItem(r, c, item)
+
+    def select_by_sync_index(self, sync_index: int) -> None:
+        self._suppress = True
+        try:
+            for r, row in enumerate(self._rows):
+                if row.sync_index == sync_index:
+                    self.selectRow(r)
+                    self.scrollTo(self.model().index(r, 0))
+                    break
+        finally:
+            self._suppress = False
+
+    def row_for_sync_index(self, sync_index: int) -> BlockRow | None:
+        for row in self._rows:
+            if row.sync_index == sync_index:
+                return row
+        return None
+
+    # ── private ───────────────────────────────────────────────────────────────
+
+    def _on_selection(self) -> None:
+        if self._suppress:
+            return
+        selected = self.selectionModel().selectedRows()
+        if selected:
+            r = selected[0].row()
+            if r < len(self._rows):
+                self.blockSelected.emit(self._rows[r].sync_index)

+ 137 - 0
apps/gui/src/gui/controls_panel.py

@@ -0,0 +1,137 @@
+"""
+Left-panel widget: editable hardware delay / raster spinboxes.
+"""
+from __future__ import annotations
+
+import json
+
+from PySide6.QtCore import Signal
+from src.gui.scheme_panel import system_is_dark
+from PySide6.QtWidgets import (
+    QWidget, QVBoxLayout, QGroupBox, QFormLayout,
+    QDoubleSpinBox, QPushButton, QGridLayout, QFileDialog,
+)
+
+
+# (attr_name, label, unit_suffix, scale_to_unit, min_val, max_val, step)
+_FIELDS = [
+    ("RF_DELAY",             "RF Delay",        "ns",  1e9,  0.0, 1e7,  1.0),
+    ("TR_DELAY",             "TR Delay",         "ns",  1e9,  0.0, 1e7,  1.0),
+    ("START_DELAY",          "Start Delay",      "ns",  1e9,  0.0, 1e7,  10.0),
+    ("MIN_BLOCK_DURATION",   "Min Block Dur",    "ns",  1e9,  0.0, 1e7,  1.0),
+    ("rf_raster_time",       "RF Raster",        "µs",  1e6,  0.001, 100.0, 0.01),
+    ("grad_raster_time",     "Grad Raster",      "µs",  1e6,  0.001, 1000.0, 0.1),
+    ("adc_raster_time",      "ADC Raster",       "ns",  1e9,  0.001, 1e6,  1.0),
+    ("block_duration_raster","Block Raster",     "µs",  1e6,  0.001, 100.0, 0.01),
+]
+
+
+class DelayControlsPanel(QWidget):
+    rerun = Signal()        # user clicked "Apply & Rerun"
+    reloadConfig = Signal() # user clicked "Reload Config"
+    saveConfig = Signal()   # user clicked "Save HW Config"
+
+    def __init__(self, parent: QWidget | None = None):
+        super().__init__(parent)
+        outer = QVBoxLayout(self)
+        outer.setContentsMargins(4, 4, 4, 4)
+
+        grp = QGroupBox("Hardware Delays / Rasters")
+        form = QFormLayout(grp)
+        form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+
+        self._spinboxes: dict[str, tuple[QDoubleSpinBox, float]] = {}
+        self._defaults: dict[str, float] = {}
+
+        for attr, label, unit, scale, mn, mx, step in _FIELDS:
+            sb = QDoubleSpinBox()
+            sb.setDecimals(3)
+            sb.setSuffix(f"  {unit}")
+            sb.setRange(mn, mx)
+            sb.setSingleStep(step)
+            sb.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
+            sb.valueChanged.connect(self._mark_modified)
+            form.addRow(f"{label}:", sb)
+            self._spinboxes[attr] = (sb, scale)
+
+        outer.addWidget(grp)
+
+        # 2×2 grid: prevents text overflow on narrow left panel (min 220 px)
+        self.btn_apply  = QPushButton("Apply && Rerun")
+        self.btn_reset  = QPushButton("Reset")
+        self.btn_reload = QPushButton("Reload Config")
+        self.btn_save   = QPushButton("Save HW Config")
+        grid = QGridLayout()
+        grid.setSpacing(4)
+        grid.addWidget(self.btn_apply,  0, 0)
+        grid.addWidget(self.btn_reset,  0, 1)
+        grid.addWidget(self.btn_reload, 1, 0)
+        grid.addWidget(self.btn_save,   1, 1)
+        outer.addLayout(grid)
+
+        self.btn_apply.clicked.connect(self.rerun)
+        self.btn_reload.clicked.connect(self.reloadConfig)
+        self.btn_reset.clicked.connect(self._on_reset)
+        self.btn_save.clicked.connect(self._on_save)
+        outer.addStretch()
+
+    # ------------------------------------------------------------------
+    # Public API
+    # ------------------------------------------------------------------
+
+    def load_from_hw(self, hw) -> None:
+        for attr, (sb, scale) in self._spinboxes.items():
+            val = getattr(hw, attr, 0.0)
+            self._defaults[attr] = val
+            sb.blockSignals(True)
+            sb.setValue(val * scale)
+            sb.blockSignals(False)
+            sb.setStyleSheet("")
+
+    def get_overrides(self) -> dict:
+        """Return dict of {attr: value_in_SI_units}."""
+        return {attr: sb.value() / scale
+                for attr, (sb, scale) in self._spinboxes.items()}
+
+    # ------------------------------------------------------------------
+    # Private helpers
+    # ------------------------------------------------------------------
+
+    def _mark_modified(self) -> None:
+        sender = self.sender()
+        for attr, (sb, scale) in self._spinboxes.items():
+            if sb is sender:
+                default = self._defaults.get(attr)
+                is_modified = (default is not None and
+                               abs(sb.value() / scale - default) > 1e-15)
+                if is_modified:
+                    bg = "#2a2400" if system_is_dark() else "#fffde7"
+                    sb.setStyleSheet(f"background-color: {bg};")
+                else:
+                    sb.setStyleSheet("")
+                break
+
+    def _on_reset(self) -> None:
+        for attr, (sb, scale) in self._spinboxes.items():
+            default = self._defaults.get(attr)
+            if default is not None:
+                sb.blockSignals(True)
+                sb.setValue(default * scale)
+                sb.blockSignals(False)
+                sb.setStyleSheet("")
+
+    def _on_save(self) -> None:
+        path, _ = QFileDialog.getSaveFileName(
+            self, "Save hardware config", "hw_config.json",
+            "JSON files (*.json)"
+        )
+        if not path:
+            return
+        overrides = self.get_overrides()
+        try:
+            with open(path, "w", encoding="utf-8") as fh:
+                json.dump(overrides, fh, indent=2)
+        except OSError as exc:
+            from PySide6.QtWidgets import QMessageBox
+            QMessageBox.critical(self, "Save failed", str(exc))
+        self.saveConfig.emit()

+ 519 - 0
apps/gui/src/gui/plot_panel.py

@@ -0,0 +1,519 @@
+"""
+Central plot panel — individual pg.PlotWidget rows on a shared X axis.
+
+Row visibility:
+  RF Magnitude   — always visible, no toggle
+  RF I/Q         — optional, hidden by default
+  Gx / Gy / Gz   — optional, hidden by default
+  Gate RF/ADC/TR — optional, hidden by default
+  Block dur.     — optional, hidden by default
+
+Toggle buttons in the header bar show/hide entire rows.
+Visibility state is preserved across plot_all() calls (e.g. after rerun).
+"""
+from __future__ import annotations
+
+from typing import List, Optional
+
+import numpy as np
+import pyqtgraph as pg
+from PySide6.QtCore import Signal, Qt, QPoint
+from PySide6.QtGui import QColor, QFont
+from PySide6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QMenu,
+    QApplication, QFileDialog,
+)
+
+from src.gui.adapters import (
+    BlockRow, gate_to_step, build_block_rows, find_block_at_time,
+)
+from src.gui.scheme_panel import system_is_dark
+
+# Waveform / overlay colours (used for curve pens and region fills)
+_C = {
+    "rf_mag":   "#d63031",
+    "rf_real":  "#00b894",
+    "rf_imag":  "#0984e3",
+    "gx":       "#e17055",
+    "gy":       "#6c5ce7",
+    "gz":       "#00cec9",
+    "gate_rf":  "#d63031",
+    "gate_adc": "#0984e3",
+    "gate_tr":  "#6c5ce7",
+    "cl":       "#636e72",
+    "start_d":  "#fd79a8",
+    "rf_d":     "#fdcb6e",
+    "tr_d":     "#74b9ff",
+    "bound":    "#b2bec3",
+}
+
+# Toggle-button label colours — ≥4.5:1 contrast on each background.
+_C_TEXT_LIGHT = {
+    "rf_mag":   "#b71c1c",
+    "rf_real":  "#00695c",
+    "rf_imag":  "#01579b",
+    "gx":       "#bf360c",
+    "gy":       "#4527a0",
+    "gz":       "#006064",
+    "gate_rf":  "#b71c1c",
+    "gate_adc": "#01579b",
+    "gate_tr":  "#4527a0",
+    "cl":       "#37474f",
+}
+_C_TEXT_DARK = {
+    "rf_mag":   "#ef5350",
+    "rf_real":  "#26d4b0",
+    "rf_imag":  "#42a5f5",
+    "gx":       "#ff8a65",
+    "gy":       "#9575cd",
+    "gz":       "#26ceca",
+    "gate_rf":  "#ef5350",
+    "gate_adc": "#42a5f5",
+    "gate_tr":  "#9575cd",
+    "cl":       "#90a4ae",
+}
+
+_MAX_VLINES = 400
+
+
+class PlotPanel(QWidget):
+    """
+    All waveforms and sync gates on a shared time axis.
+    Emits blockClicked(sync_index) on left-click.
+    Emits timeHovered(t_s, channel, value) for status-bar updates.
+    """
+    blockClicked = Signal(int)
+    timeHovered  = Signal(float, str, float)
+
+    def __init__(self, parent: QWidget | None = None):
+        super().__init__(parent)
+
+        _dark = system_is_dark()
+        pg.setConfigOptions(
+            antialias=True,
+            background=QColor("#1e1e1e") if _dark else QColor("#ffffff"),
+            foreground="w" if _dark else "k",
+        )
+
+        root = QVBoxLayout(self)
+        root.setContentsMargins(0, 0, 0, 0)
+        root.setSpacing(0)
+
+        # ── toggle button bar ─────────────────────────────────────────────
+        self._btn_bar = QWidget()
+        self._btn_bar.setObjectName("PlotBtnBar")
+        self._btn_bar.setStyleSheet(
+            "#PlotBtnBar { border-bottom: 1px solid palette(mid); }"
+        )
+        self._btn_lay = QHBoxLayout(self._btn_bar)
+        self._btn_lay.setContentsMargins(6, 3, 6, 3)
+        self._btn_lay.setSpacing(4)
+        lbl = QLabel("Show:")
+        lbl.setFont(QFont("Arial", 9))
+        self._btn_lay.addWidget(lbl)   # index 0 — stays forever
+        self._btn_lay.addStretch()     # index 1 — stays forever; buttons inserted before it
+        root.addWidget(self._btn_bar)
+
+        # ── plot container ────────────────────────────────────────────────
+        self._plot_area = QWidget()
+        self._plot_area_lay = QVBoxLayout(self._plot_area)
+        self._plot_area_lay.setContentsMargins(0, 0, 0, 0)
+        self._plot_area_lay.setSpacing(1)
+        root.addWidget(self._plot_area, stretch=1)
+
+        # State
+        self._plot_widgets: dict[str, pg.PlotWidget] = {}
+        self._plots:        dict[str, pg.PlotItem]   = {}
+        self._curves:       dict[str, pg.PlotDataItem] = {}
+        self._row_btns:     dict[str, QPushButton]   = {}
+        self._block_rows:   List[BlockRow] = []
+        self._ref_plot:     Optional[pg.PlotItem] = None
+        # Remembered visibility (persists across plot_all calls).
+        # For simple rows the key is the row key; for combined rows (gradients,
+        # gates) the key is the individual curve key (gx, gy, gz, gate_rf, …).
+        self._visible: set[str] = set()
+        # Maps curve_key → row_key for curves that live in a combined row.
+        self._curve_to_row: dict[str, str] = {}
+        # Maps row_key → [curve_keys] for combined rows.
+        self._row_curves: dict[str, list[str]] = {}
+
+    # ------------------------------------------------------------------ #
+    #  Public API                                                          #
+    # ------------------------------------------------------------------ #
+
+    def plot_all(self, seq_data: dict, sync_data: dict) -> None:
+        self._clear()
+        self._block_rows = build_block_rows(seq_data, sync_data)
+
+        self._build_rf_rows(seq_data)
+        self._build_gradients_row(seq_data)
+        self._build_gates_row(sync_data)
+        self._build_cl_row(sync_data)
+
+        durs      = sync_data["blocks_duration"]
+        cumtimes  = np.cumsum([0.0] + list(durs))
+        self._draw_boundaries(cumtimes)
+        self._draw_delay_regions()
+        self._attach_mouse_events()
+
+        # Apply visibility.
+        # rf_mag is always on (fall back to first row if sequence has no RF).
+        always_key = "rf_mag" if "rf_mag" in self._plot_widgets else next(
+            iter(self._plot_widgets), None
+        )
+        for key, pw in self._plot_widgets.items():
+            if key == always_key:
+                pw.setVisible(True)
+            elif key in self._row_curves:
+                # Combined row: visible when ≥1 of its curves is on.
+                # Also restore per-curve visibility and button states.
+                curves = self._row_curves[key]
+                for c_key in curves:
+                    on = c_key in self._visible
+                    c = self._curves.get(c_key)
+                    if c:
+                        c.setVisible(on)
+                    if c_key in self._row_btns:
+                        self._row_btns[c_key].setChecked(on)
+                pw.setVisible(any(c in self._visible for c in curves))
+            else:
+                # Simple row: row key itself tracked in _visible.
+                on = key in self._visible
+                pw.setVisible(on)
+                if key in self._row_btns:
+                    self._row_btns[key].setChecked(on)
+
+    def highlight_block(self, sync_index: int) -> None:
+        for row in self._block_rows:
+            if row.sync_index == sync_index:
+                mid  = (row.t_start + row.t_end) / 2.0
+                span = max(row.t_end - row.t_start, 1e-6)
+                if self._ref_plot:
+                    self._ref_plot.setXRange(mid - span * 5, mid + span * 5)
+                break
+
+    def fit_all(self) -> None:
+        if self._ref_plot:
+            self._ref_plot.enableAutoRange(axis="x")
+            for p in self._plots.values():
+                p.enableAutoRange(axis="y")
+
+    # ------------------------------------------------------------------ #
+    #  Row builders                                                        #
+    # ------------------------------------------------------------------ #
+
+    def _build_rf_rows(self, seq_data: dict) -> None:
+        rf_raw = seq_data.get("rf")
+        t_rf   = seq_data.get("t_rf")
+        if rf_raw is None or t_rf is None or len(rf_raw) == 0:
+            return
+
+        rf  = np.asarray(rf_raw)
+        t   = np.asarray(t_rf, dtype=float)
+        mag = np.abs(rf)
+
+        # RF Magnitude — always on, no toggle button
+        p = self._add_row("rf_mag", "RF Mag", "a.u.", h=90)
+        self._curves["rf_mag"] = p.plot(
+            t, mag, pen=pg.mkPen(_C["rf_mag"], width=1.5), name="RF Mag"
+        )
+
+        # RF I/Q — optional
+        p2 = self._add_row("rf_iq", "RF I/Q", "a.u.", h=75)
+        self._curves["rf_real"] = p2.plot(
+            t, rf.real, pen=pg.mkPen(_C["rf_real"], width=1.2), name="RF Re"
+        )
+        self._curves["rf_imag"] = p2.plot(
+            t, rf.imag, pen=pg.mkPen(_C["rf_imag"], width=1.2), name="RF Im"
+        )
+        self._add_row_btn("rf_iq", "RF I/Q", "rf_real")
+
+    def _build_gradients_row(self, seq_data: dict) -> None:
+        axes = [("gx", "Gx"), ("gy", "Gy"), ("gz", "Gz")]
+        present = [
+            (key, lbl) for key, lbl in axes
+            if key in seq_data and len(seq_data.get(key, []))
+        ]
+        if not present:
+            return
+        curve_keys = [key for key, _ in present]
+        self._row_curves["gradients"] = curve_keys
+        for key in curve_keys:
+            self._curve_to_row[key] = "gradients"
+
+        p = self._add_row("gradients", "Gradients", "Hz/m", h=100)
+        p.addLegend(offset=(10, 5))
+        for key, label in present:
+            t = np.asarray(seq_data[f"t_{key}"], dtype=float)
+            v = np.asarray(seq_data[key],         dtype=float)
+            self._curves[key] = p.plot(
+                t, v, pen=pg.mkPen(_C[key], width=1.5), name=label
+            )
+            self._add_curve_btn(key, label, key)
+
+    def _build_gates_row(self, sync_data: dict) -> None:
+        durs  = sync_data["blocks_duration"]
+        gates = [
+            ("gate_rf",        "Gate RF",  "gate_rf"),
+            ("gate_adc",       "Gate ADC", "gate_adc"),
+            ("gate_tr_switch", "Gate TR",  "gate_tr"),
+        ]
+        curve_keys = [dk for dk, _, _ in gates]
+        self._row_curves["gates"] = curve_keys
+        for key in curve_keys:
+            self._curve_to_row[key] = "gates"
+
+        p = self._add_row("gates", "Gates", "", h=80)
+        p.setYRange(-0.15, 1.15)
+        p.addLegend(offset=(10, 5))
+        for data_key, label, color_key in gates:
+            t, v  = gate_to_step(sync_data[data_key], durs)
+            color = _C[color_key]
+            self._curves[data_key] = p.plot(
+                t, v,
+                pen=pg.mkPen(color, width=1.5),
+                fillLevel=0.0,
+                brush=pg.mkBrush(color + "40"),
+                name=label,
+            )
+            self._add_curve_btn(data_key, label, color_key)
+
+    def _build_cl_row(self, sync_data: dict) -> None:
+        durs     = sync_data["blocks_duration"]
+        cumtimes = np.cumsum([0.0] + list(durs))
+        t_pts, v_pts = [], []
+        for i, d in enumerate(durs):
+            t_pts += [cumtimes[i], cumtimes[i + 1]]
+            v_pts += [d * 1e6,     d * 1e6]
+        p = self._add_row("cl", "Block dur.", "µs", h=65)
+        self._curves["cl"] = p.plot(
+            np.array(t_pts), np.array(v_pts),
+            pen=pg.mkPen(_C["cl"], width=1.2),
+        )
+        self._add_row_btn("cl", "Dur", "cl")
+
+    # ------------------------------------------------------------------ #
+    #  Overlays                                                            #
+    # ------------------------------------------------------------------ #
+
+    def _draw_boundaries(self, cumtimes: np.ndarray) -> None:
+        n    = len(cumtimes)
+        step = max(1, n // _MAX_VLINES)
+        pen  = pg.mkPen(_C["bound"] + "80", width=0.5, style=Qt.DotLine)
+        for plot in self._plots.values():
+            for t in cumtimes[::step]:
+                plot.addItem(
+                    pg.InfiniteLine(pos=float(t), angle=90, pen=pen, movable=False)
+                )
+
+    def _draw_delay_regions(self) -> None:
+        color_map = {
+            "START": _C["start_d"] + "30",
+            "RF":    _C["rf_d"]    + "30",
+            "TR":    _C["tr_d"]    + "30",
+        }
+        for row in self._block_rows:
+            if not row.is_delay:
+                continue
+            color = color_map.get(row.delay_type, "#ffffff30")
+            for plot in self._plots.values():
+                region = pg.LinearRegionItem(
+                    values=[row.t_start, row.t_end],
+                    brush=pg.mkBrush(color),
+                    pen=pg.mkPen(None),
+                    movable=False,
+                )
+                region.setZValue(-10)
+                plot.addItem(region)
+
+    # ------------------------------------------------------------------ #
+    #  Mouse interactions                                                  #
+    # ------------------------------------------------------------------ #
+
+    def _attach_mouse_events(self) -> None:
+        for name, plot in self._plots.items():
+            plot.scene().sigMouseClicked.connect(
+                lambda ev, p=plot, n=name: self._on_click(ev, p, n)
+            )
+            plot.scene().sigMouseMoved.connect(
+                lambda pos, p=plot, n=name: self._on_hover(pos, p, n)
+            )
+
+    def _on_click(self, ev, plot: pg.PlotItem, channel: str) -> None:
+        if not plot.sceneBoundingRect().contains(ev.scenePos()):
+            return
+        if ev.button() == Qt.RightButton:
+            self._show_context_menu(ev, plot, channel)
+            ev.accept()
+            return
+        if ev.button() == Qt.LeftButton:
+            vb = plot.getViewBox()
+            pt = vb.mapSceneToView(ev.scenePos())
+            block = find_block_at_time(pt.x(), self._block_rows)
+            if block is not None:
+                self.blockClicked.emit(block.sync_index)
+            ev.accept()
+
+    def _on_hover(self, scene_pos, plot: pg.PlotItem, channel: str) -> None:
+        if not plot.sceneBoundingRect().contains(scene_pos):
+            return
+        vb = plot.getViewBox()
+        pt = vb.mapSceneToView(scene_pos)
+        self.timeHovered.emit(pt.x(), channel, pt.y())
+
+    def _show_context_menu(self, ev, plot: pg.PlotItem, channel: str) -> None:
+        vb    = plot.getViewBox()
+        t_val = vb.mapSceneToView(ev.scenePos()).x()
+
+        menu     = QMenu(self)
+        a_fit    = menu.addAction("Fit all")
+        a_fit.triggered.connect(self.fit_all)
+        a_reset  = menu.addAction("Reset zoom")
+        a_reset.triggered.connect(lambda: vb.autoRange())
+        menu.addSeparator()
+        a_grid = menu.addAction("Toggle grid")
+        a_grid.triggered.connect(
+            lambda: plot.showGrid(
+                x=not plot.ctrl.xGridCheck.isChecked(),
+                y=not plot.ctrl.yGridCheck.isChecked(),
+                alpha=0.3,
+            )
+        )
+        a_copy = menu.addAction(f"Copy time  {t_val * 1e6:.4f} µs")
+        a_copy.triggered.connect(
+            lambda: QApplication.clipboard().setText(f"{t_val * 1e6:.6f}")
+        )
+        block = find_block_at_time(t_val, self._block_rows)
+        if block is not None:
+            menu.addSeparator()
+            a_jump = menu.addAction(
+                f"Jump to block #{block.sync_index}  [{block.block_type}]"
+            )
+            a_jump.triggered.connect(lambda: self.blockClicked.emit(block.sync_index))
+        menu.addSeparator()
+        a_export = menu.addAction("Export plot as PNG…")
+        a_export.triggered.connect(lambda: self._export_plot(plot, channel))
+        sp = ev.screenPos()
+        menu.exec(QPoint(int(sp.x()), int(sp.y())))
+
+    def _export_plot(self, plot: pg.PlotItem, channel: str) -> None:
+        path, _ = QFileDialog.getSaveFileName(
+            self, f"Export {channel}", f"{channel}.png",
+            "PNG images (*.png);;All files (*)",
+        )
+        if not path:
+            return
+        pg.exporters.ImageExporter(plot).export(path)
+
+    # ------------------------------------------------------------------ #
+    #  Helpers                                                             #
+    # ------------------------------------------------------------------ #
+
+    def _add_row(self, key: str, label: str, units: str, h: int) -> pg.PlotItem:
+        pw = pg.PlotWidget()
+        pw.setMinimumHeight(h)
+        pw.setMaximumHeight(h + 40)
+        p = pw.getPlotItem()
+        p.setLabel("left", label, units=units)
+        p.showGrid(x=True, y=True, alpha=0.25)
+        self._plot_widgets[key] = pw
+        self._plots[key]        = p
+        self._plot_area_lay.addWidget(pw)
+        if self._ref_plot is None:
+            self._ref_plot = p
+        else:
+            p.setXLink(self._ref_plot)
+        return p
+
+    def _add_row_btn(self, key: str, label: str, color_key: str) -> None:
+        _text = _C_TEXT_DARK if system_is_dark() else _C_TEXT_LIGHT
+        color = _text.get(color_key, _C.get(color_key, "#555555"))
+        btn   = QPushButton(label)
+        btn.setCheckable(True)
+        btn.setChecked(key in self._visible)
+        btn.setFixedHeight(20)
+        btn.setStyleSheet(
+            f"QPushButton {{"
+            f"  color: palette(mid); font-size: 11px;"
+            f"  padding: 0px 7px;"
+            f"  border: 1px solid palette(mid);"
+            f"  border-radius: 3px;"
+            f"}}"
+            f"QPushButton:checked {{"
+            f"  color: {color}; font-weight: bold;"
+            f"  border-color: {color};"
+            f"  background: palette(alternateBase);"
+            f"}}"
+        )
+        btn.toggled.connect(lambda checked, k=key: self._on_row_toggle(k, checked))
+        # Insert before the trailing stretch (last item in _btn_lay)
+        self._btn_lay.insertWidget(self._btn_lay.count() - 1, btn)
+        self._row_btns[key] = btn
+
+    def _on_row_toggle(self, key: str, checked: bool) -> None:
+        if checked:
+            self._visible.add(key)
+        else:
+            self._visible.discard(key)
+        pw = self._plot_widgets.get(key)
+        if pw is not None:
+            pw.setVisible(checked)
+
+    def _add_curve_btn(self, curve_key: str, label: str, color_key: str) -> None:
+        """Toggle button for one curve inside a combined row."""
+        _text = _C_TEXT_DARK if system_is_dark() else _C_TEXT_LIGHT
+        color = _text.get(color_key, _C.get(color_key, "#555555"))
+        btn   = QPushButton(label)
+        btn.setCheckable(True)
+        btn.setChecked(curve_key in self._visible)
+        btn.setFixedHeight(20)
+        btn.setStyleSheet(
+            f"QPushButton {{"
+            f"  color: palette(mid); font-size: 11px;"
+            f"  padding: 0px 7px;"
+            f"  border: 1px solid palette(mid);"
+            f"  border-radius: 3px;"
+            f"}}"
+            f"QPushButton:checked {{"
+            f"  color: {color}; font-weight: bold;"
+            f"  border-color: {color};"
+            f"  background: palette(alternateBase);"
+            f"}}"
+        )
+        btn.toggled.connect(
+            lambda checked, k=curve_key: self._on_curve_toggle(k, checked)
+        )
+        self._btn_lay.insertWidget(self._btn_lay.count() - 1, btn)
+        self._row_btns[curve_key] = btn
+
+    def _on_curve_toggle(self, curve_key: str, checked: bool) -> None:
+        if checked:
+            self._visible.add(curve_key)
+        else:
+            self._visible.discard(curve_key)
+        c = self._curves.get(curve_key)
+        if c is not None:
+            c.setVisible(checked)
+        # Show the parent row when ≥1 of its curves is on; hide when all off.
+        row_key = self._curve_to_row.get(curve_key)
+        if row_key:
+            any_on = any(k in self._visible for k in self._row_curves.get(row_key, []))
+            pw = self._plot_widgets.get(row_key)
+            if pw is not None:
+                pw.setVisible(any_on)
+
+    def _clear(self) -> None:
+        for pw in self._plot_widgets.values():
+            self._plot_area_lay.removeWidget(pw)
+            pw.deleteLater()
+        self._plot_widgets.clear()
+        self._plots.clear()
+        self._curves.clear()
+        self._ref_plot = None
+        self._block_rows.clear()
+        for btn in self._row_btns.values():
+            self._btn_lay.removeWidget(btn)
+            btn.deleteLater()
+        self._row_btns.clear()
+        self._curve_to_row.clear()
+        self._row_curves.clear()

+ 145 - 0
apps/gui/src/gui/preview_panel.py

@@ -0,0 +1,145 @@
+"""
+Right-side panel: tabbed previews for Sync XML, POST JSON, block details,
+warnings, and live log output.
+"""
+from __future__ import annotations
+
+import json as _json
+from datetime import datetime
+
+from PySide6.QtWidgets import (
+    QApplication, QWidget, QVBoxLayout, QTabWidget, QTextEdit,
+)
+from PySide6.QtGui import QFont, QColor, QPalette, QTextCursor, QTextCharFormat
+
+from src.gui.adapters import BlockRow
+
+
+_MONO = QFont("Courier New", 9)
+
+# Tab indices (keep in sync with addTab order below)
+_TAB_DETAILS  = 0
+_TAB_WARNINGS = 1
+_TAB_XML      = 2
+_TAB_JSON     = 3
+_TAB_LOG      = 4
+
+
+class PreviewPanel(QWidget):
+    def __init__(self, parent: QWidget | None = None):
+        super().__init__(parent)
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+
+        self.tabs = QTabWidget()
+        layout.addWidget(self.tabs)
+
+        self._detail_edit = self._make_edit(
+            "Click a block in the scheme, plot or table to see details…"
+        )
+        self.tabs.addTab(self._detail_edit, "Block Details")   # 0
+
+        self._warn_edit = self._make_edit("No warnings yet.")
+        self.tabs.addTab(self._warn_edit, "Warnings")           # 1
+
+        self._xml_edit = self._make_edit(
+            "Run interpretation to populate XML preview…"
+        )
+        self.tabs.addTab(self._xml_edit, "Sync XML")            # 2
+
+        self._json_edit = self._make_edit(
+            "Export artifacts to populate POST JSON preview…"
+        )
+        self.tabs.addTab(self._json_edit, "POST JSON")          # 3
+
+        self._log_edit = self._make_edit("")
+        self._log_edit.setLineWrapMode(QTextEdit.WidgetWidth)
+        self.tabs.addTab(self._log_edit, "Log")                 # 4
+
+    # ── block details ─────────────────────────────────────────────────────────
+
+    def show_block_details(self, row: BlockRow | None) -> None:
+        if row is None:
+            self._detail_edit.clear()
+            return
+        lines = [
+            f"Sync index    : {row.sync_index}",
+            f"Orig index    : {row.orig_index if row.orig_index >= 0 else '— (inserted)'}",
+            f"Type          : {row.block_type}",
+            f"Duration      : {row.duration * 1e6:.4f} µs",
+            f"T start       : {row.t_start * 1e6:.4f} µs",
+            f"T end         : {row.t_end * 1e6:.4f} µs",
+            f"Is delay      : {row.is_delay}  ({row.delay_type or '—'})",
+            "─" * 32,
+            f"Gate RF       : {row.gate_rf}",
+            f"Gate ADC      : {row.gate_adc}",
+            f"Gate TR       : {row.gate_tr}",
+            "─" * 32,
+            f"Has RF        : {row.has_rf}",
+            f"Has ADC       : {row.has_adc}",
+            f"Has Grad      : {row.has_grad}",
+        ]
+        self._detail_edit.setPlainText("\n".join(lines))
+        self.tabs.setCurrentIndex(_TAB_DETAILS)
+
+    # ── warnings ──────────────────────────────────────────────────────────────
+
+    def set_warnings(self, warnings: list[str]) -> None:
+        if warnings:
+            self._warn_edit.setPlainText("\n".join(f"⚠  {w}" for w in warnings))
+            self.tabs.setTabText(_TAB_WARNINGS, f"Warnings ({len(warnings)})")
+        else:
+            self._warn_edit.setPlainText("No warnings.")
+            self.tabs.setTabText(_TAB_WARNINGS, "Warnings")
+
+    def add_error(self, msg: str) -> None:
+        current = self._warn_edit.toPlainText()
+        self._warn_edit.setPlainText(
+            (current + "\n" if current else "") + f"✗  {msg}"
+        )
+        n = self._warn_edit.toPlainText().count("\n") + 1
+        self.tabs.setTabText(_TAB_WARNINGS, f"Warnings ({n})")
+        self.tabs.setCurrentIndex(_TAB_WARNINGS)
+
+    # ── xml / json previews ───────────────────────────────────────────────────
+
+    def set_xml_text(self, text: str) -> None:
+        self._xml_edit.setPlainText(text)
+
+    def set_post_json(self, data: dict) -> None:
+        self._json_edit.setPlainText(_json.dumps(data, indent=2, default=str))
+
+    def set_post_json_text(self, text: str) -> None:
+        self._json_edit.setPlainText(text)
+
+    # ── log ───────────────────────────────────────────────────────────────────
+
+    def append_log(self, msg: str, error: bool = False) -> None:
+        ts   = datetime.now().strftime("%H:%M:%S")
+        line = f"[{ts}] {msg}"
+        cursor = self._log_edit.textCursor()
+        cursor.movePosition(QTextCursor.End)
+        fmt = QTextCharFormat()
+        if error:
+            fmt.setForeground(QColor("#ef5350"))
+        else:
+            fmt.setForeground(QApplication.palette().color(QPalette.Text))
+        cursor.setCharFormat(fmt)
+        cursor.insertText(line + "\n")
+        self._log_edit.setTextCursor(cursor)
+        self._log_edit.ensureCursorVisible()
+
+    def clear_log(self) -> None:
+        self._log_edit.clear()
+
+    # ── helpers ───────────────────────────────────────────────────────────────
+
+    @staticmethod
+    def _make_edit(placeholder: str = "") -> QTextEdit:
+        edit = QTextEdit()
+        edit.setReadOnly(True)
+        edit.setFont(_MONO)
+        edit.setLineWrapMode(QTextEdit.NoWrap)
+        if placeholder:
+            edit.setPlaceholderText(placeholder)
+        return edit

+ 294 - 0
apps/gui/src/gui/scheme_panel.py

@@ -0,0 +1,294 @@
+"""
+Compact horizontal block-type timeline shown after sequence loading.
+
+Each sync block is rendered as a proportional coloured segment.
+Background, text and chrome colours are derived from the OS palette at paint
+time so the widget looks correct in both light and dark OS themes.
+Click or hover to inspect; emits blockClicked(sync_index).
+"""
+from __future__ import annotations
+
+import sys
+from typing import List, Optional
+
+from PySide6.QtCore import Signal, Qt, QRect
+from PySide6.QtGui import (
+    QPainter, QColor, QPen, QFont, QFontMetrics, QPalette,
+)
+from PySide6.QtWidgets import QWidget, QToolTip, QSizePolicy, QApplication
+
+from src.gui.adapters import BlockRow
+
+
+def system_is_dark() -> bool:
+    """Return True when the OS is running in dark mode.
+
+    On Windows the registry is authoritative — Qt's widget-area palette may
+    stay light even when the title bar is dark (Windows styles them separately).
+    Other platforms fall back to QPalette.Window lightness.
+    """
+    if sys.platform == "win32":
+        try:
+            import winreg
+            key = winreg.OpenKey(
+                winreg.HKEY_CURRENT_USER,
+                r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
+            )
+            val, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
+            winreg.CloseKey(key)
+            return val == 0          # 0 = dark, 1 = light
+        except Exception:
+            pass
+    return QApplication.palette().color(QPalette.Window).lightness() < 128
+
+# ── block colour map (semantic, theme-neutral vivid hues) ────────────────────
+
+def _row_color(row: BlockRow) -> QColor:
+    if row.is_delay:
+        return QColor({
+            "START": "#f48fb1",   # pink
+            "RF":    "#ffcc80",   # amber
+            "TR":    "#90caf9",   # light-blue
+        }.get(row.delay_type, "#bdbdbd"))
+    types = set(row.block_type.split(", "))
+    if "RF" in types and "ADC" in types:
+        return QColor("#ffb74d")   # orange  — mixed
+    if "RF" in types:
+        return QColor("#e53935")   # red
+    if "ADC" in types:
+        return QColor("#43a047")   # green
+    if "GRAD" in types:
+        return QColor("#8e24aa")   # purple
+    return QColor("#bdbdbd")       # gray    — plain / empty
+
+
+_LEGEND = [
+    ("#f48fb1", "Start dly"),
+    ("#ffcc80", "RF dly"),
+    ("#90caf9", "TR dly"),
+    ("#e53935", "RF"),
+    ("#43a047", "ADC"),
+    ("#8e24aa", "Grad"),
+    ("#ffb74d", "RF+ADC"),
+    ("#bdbdbd", "Delay"),
+]
+
+# ── geometry constants ────────────────────────────────────────────────────────
+
+_LEGEND_H = 16   # legend swatch row height (px)
+_BAR_H    = 28   # coloured block bar height (px)
+_TICK_H   = 18   # time-tick label row height (px)
+_TOTAL_H  = _LEGEND_H + _BAR_H + _TICK_H   # 62 px total
+
+_TICK_FONT   = QFont("Arial", 7)
+_LEGEND_FONT = QFont("Arial", 7)
+
+
+class SchemePanel(QWidget):
+    """
+    Proportional block-type timeline.  Adapts to the OS light/dark palette —
+    background, text and chrome colours are derived from QPalette at paint time.
+    Emits blockClicked(sync_index) on left-click.
+    """
+    blockClicked = Signal(int)
+
+    def __init__(self, parent: QWidget | None = None):
+        super().__init__(parent)
+        # WA_OpaquePaintEvent: Qt won't pre-clear background before paintEvent.
+        # WA_NoSystemBackground: OS won't paint its own background colour.
+        # Both are still needed — we fill explicitly from the palette instead of
+        # a hardcoded white, and these attributes prevent any flicker or bleed.
+        self.setAttribute(Qt.WA_OpaquePaintEvent, True)
+        self.setAttribute(Qt.WA_NoSystemBackground, True)
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+        self.setFixedHeight(_TOTAL_H)
+        self.setMouseTracking(True)
+        self._rows: List[BlockRow] = []
+        self._total_dur: float = 0.0
+        self._selected: int = -1
+        self._hovered: int = -1
+
+    # ── public ───────────────────────────────────────────────────────────────
+
+    def load_rows(self, rows: List[BlockRow]) -> None:
+        self._rows = rows
+        self._total_dur = rows[-1].t_end if rows else 0.0
+        self._hovered = -1
+        self.update()
+
+    def select_block(self, sync_index: int) -> None:
+        if self._selected != sync_index:
+            self._selected = sync_index
+            self.update()
+
+    def clear(self) -> None:
+        self._rows = []
+        self._total_dur = 0.0
+        self._selected = -1
+        self._hovered = -1
+        self.update()
+
+    # ── palette helper ───────────────────────────────────────────────────────
+
+    def _theme_colors(self) -> dict:
+        """Return explicit paint-time colours for the current OS theme.
+
+        Explicit hex values are used instead of QPalette roles because on
+        Windows 11 the widget-area palette may not reflect dark mode even
+        when the title bar is dark (see system_is_dark()).
+        """
+        if system_is_dark():
+            return {
+                "bg":          QColor("#1e1e1e"),
+                "text":        QColor("#e4e4e4"),
+                "tick":        QColor("#888888"),
+                "border":      QColor("#454545"),
+                "placeholder": QColor("#606060"),
+                "ph_bg":       QColor("#262626"),
+                "sel_border":  QColor("#e4e4e4"),
+            }
+        return {
+            "bg":          QColor("#ffffff"),
+            "text":        QColor("#333333"),
+            "tick":        QColor("#555555"),
+            "border":      QColor("#cccccc"),
+            "placeholder": QColor("#9e9e9e"),
+            "ph_bg":       QColor("#f5f5f5"),
+            "sel_border":  QColor("#000000"),
+        }
+
+    # ── painting ─────────────────────────────────────────────────────────────
+
+    def paintEvent(self, _event) -> None:
+        p = QPainter(self)
+        p.setRenderHint(QPainter.Antialiasing, False)
+
+        tc = self._theme_colors()
+        # First draw call: fill our own background from the OS palette so
+        # no OS-theme bleed can appear in gaps between painted rectangles.
+        p.fillRect(self.rect(), tc["bg"])
+
+        if not self._rows or self._total_dur == 0:
+            self._paint_placeholder(p, tc)
+        else:
+            self._paint_legend(p, tc)
+            self._paint_blocks(p, tc)
+            self._paint_ticks(p, tc)
+
+        p.end()
+
+    def _paint_placeholder(self, p: QPainter, tc: dict) -> None:
+        p.fillRect(self.rect(), tc["ph_bg"])
+        p.setPen(tc["placeholder"])
+        p.setFont(QFont("Arial", 9))
+        p.drawText(self.rect(), Qt.AlignCenter,
+                   "Load and run a .seq file to see the sequence scheme")
+
+    def _paint_legend(self, p: QPainter, tc: dict) -> None:
+        p.setFont(_LEGEND_FONT)
+        fm = QFontMetrics(_LEGEND_FONT)
+        x = 4
+        swatch_w = 12
+        swatch_h = _LEGEND_H - 4
+        swatch_y = 2
+        for color_hex, label in _LEGEND:
+            # coloured swatch with thin border
+            p.fillRect(x, swatch_y, swatch_w, swatch_h, QColor(color_hex))
+            p.setPen(QPen(tc["border"], 1))
+            p.drawRect(x, swatch_y, swatch_w - 1, swatch_h - 1)
+            x += swatch_w + 2
+            # label text
+            text_w = fm.horizontalAdvance(label) + 4
+            p.setPen(tc["text"])
+            p.drawText(x, 0, text_w, _LEGEND_H, Qt.AlignVCenter | Qt.AlignLeft, label)
+            x += text_w + 6
+
+    def _paint_blocks(self, p: QPainter, tc: dict) -> None:
+        w     = self.width()
+        y0    = _LEGEND_H
+        bar_h = _BAR_H
+        scale = w / self._total_dur
+
+        # Subsample dense sequences so we never draw more than 3000 rects
+        rows = self._rows
+        if len(rows) > 3000:
+            step = len(rows) // 3000 + 1
+            rows = rows[::step]
+
+        for row in rows:
+            x  = int(row.t_start * scale)
+            bw = max(1, int((row.t_end - row.t_start) * scale))
+            color = _row_color(row)
+
+            if row.sync_index == self._selected:
+                p.fillRect(x, y0, bw, bar_h, color.lighter(150))
+                p.setPen(QPen(tc["sel_border"], 1))
+                p.drawRect(x, y0, max(0, bw - 1), bar_h - 1)
+            elif row.sync_index == self._hovered:
+                p.fillRect(x, y0, bw, bar_h, color.lighter(125))
+            else:
+                p.fillRect(x, y0, bw, bar_h, color)
+
+        # bottom border line
+        p.setPen(QPen(tc["border"], 1))
+        p.drawLine(0, y0 + bar_h, w, y0 + bar_h)
+
+    def _paint_ticks(self, p: QPainter, tc: dict) -> None:
+        w       = self.width()
+        tick_y  = _LEGEND_H + _BAR_H      # top of tick row
+        label_y = tick_y + 4
+        scale   = w / self._total_dur
+        p.setFont(_TICK_FONT)
+        p.setPen(tc["tick"])
+        n_ticks = max(2, w // 90)
+        for i in range(n_ticks + 1):
+            t      = self._total_dur * i / n_ticks
+            x_tick = int(t * scale)
+            p.drawLine(x_tick, tick_y, x_tick, tick_y + 3)
+            label = (f"{t * 1e3:.2f}ms" if self._total_dur >= 1e-3
+                     else f"{t * 1e6:.0f}µs")
+            p.drawText(x_tick + 2, label_y, 70, _TICK_H - 4,
+                       Qt.AlignLeft | Qt.AlignTop, label)
+
+    # ── mouse ────────────────────────────────────────────────────────────────
+
+    def mousePressEvent(self, event) -> None:
+        row = self._row_at(event.position().x())
+        if row is not None:
+            self._selected = row.sync_index
+            self.update()
+            self.blockClicked.emit(row.sync_index)
+
+    def mouseMoveEvent(self, event) -> None:
+        row = self._row_at(event.position().x())
+        new_h = row.sync_index if row else -1
+        if new_h != self._hovered:
+            self._hovered = new_h
+            self.update()
+        if row is not None:
+            tip = (
+                f"Block #{row.sync_index}  [{row.block_type}]\n"
+                f"t = {row.t_start * 1e6:.2f} – {row.t_end * 1e6:.2f} µs  "
+                f"({(row.t_end - row.t_start) * 1e6:.2f} µs)"
+            )
+            if row.is_delay:
+                tip += f"\n↳ Inserted {row.delay_type} delay"
+            QToolTip.showText(event.globalPosition().toPoint(), tip, self)
+        else:
+            QToolTip.hideText()
+
+    def leaveEvent(self, _event) -> None:
+        if self._hovered != -1:
+            self._hovered = -1
+            self.update()
+
+    # ── helper ───────────────────────────────────────────────────────────────
+
+    def _row_at(self, x_px: float) -> Optional[BlockRow]:
+        if not self._rows or self._total_dur == 0 or self.width() == 0:
+            return None
+        t = x_px / self.width() * self._total_dur
+        for row in self._rows:
+            if row.t_start <= t < row.t_end:
+                return row
+        return None

+ 293 - 0
apps/gui/src/gui/workers.py

@@ -0,0 +1,293 @@
+"""
+QThread workers that run heavy operations off the UI thread.
+"""
+from __future__ import annotations
+
+import os
+import tempfile
+
+from PySide6.QtCore import QThread, Signal
+
+from src.hardware.constraints import HardwareConstraints
+from src.interfaces.pulseq_adapter import PulseqLoader
+from src.core.synchronizer import Synchronizer
+from src.interfaces.xml_generator import XMLGenerator
+from src.interfaces.rf_exporter import RFExporter
+from src.interfaces.gradient_exporter import GradientExporter
+from src.interfaces.picoscope_exporter import PicoScopeExporter
+
+
+class LoadInterpWorker(QThread):
+    """
+    Load a .seq file and run the synchronizer in one shot.
+    Emits finished(seq_data, sync_data, hw) or error(msg).
+    """
+    finished = Signal(object, object, object)
+    error = Signal(str)
+    log_msg = Signal(str)
+
+    def __init__(self, seq_path: str, hw_config_path: str | None = None,
+                 hw_overrides: dict | None = None):
+        super().__init__()
+        self.seq_path = seq_path
+        self.hw_config_path = hw_config_path
+        self.hw_overrides = hw_overrides or {}
+
+    def run(self):
+        try:
+            hw = HardwareConstraints(json_path=self.hw_config_path)
+            for k, v in self.hw_overrides.items():
+                setattr(hw, k, v)
+
+            self.log_msg.emit(f"Loading: {self.seq_path}")
+            loader = PulseqLoader(hw)
+            seq_data = loader.load(self.seq_path)
+            n = len(seq_data.get("blocks", []))
+            self.log_msg.emit(f"Loaded {n} blocks (zero-duration removed)")
+
+            self.log_msg.emit("Running synchronizer…")
+            sync = Synchronizer(hw)
+            sync_data = sync.process(seq_data["sequence"])
+            nb = sync_data["number_of_blocks"]
+            self.log_msg.emit(f"Sync done: {nb} sync blocks (incl. inserted delays)")
+
+            self.finished.emit(seq_data, sync_data, hw)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+class SyncOnlyWorker(QThread):
+    """
+    Re-run just the synchronizer on an already-loaded sequence,
+    applying new hw_overrides from the GUI controls.
+    """
+    finished = Signal(object, object)   # sync_data, hw
+    error = Signal(str)
+    log_msg = Signal(str)
+
+    def __init__(self, seq_data: dict, hw_config_path: str | None,
+                 hw_overrides: dict | None = None):
+        super().__init__()
+        self.seq_data = seq_data
+        self.hw_config_path = hw_config_path
+        self.hw_overrides = hw_overrides or {}
+
+    def run(self):
+        try:
+            hw = HardwareConstraints(json_path=self.hw_config_path)
+            for k, v in self.hw_overrides.items():
+                setattr(hw, k, v)
+
+            self.log_msg.emit("Re-running synchronizer with updated settings…")
+            sync = Synchronizer(hw)
+            sync_data = sync.process(self.seq_data["sequence"])
+            nb = sync_data["number_of_blocks"]
+            self.log_msg.emit(f"Sync done: {nb} sync blocks")
+
+            self.finished.emit(sync_data, hw)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+class ExportWorker(QThread):
+    """
+    Export all artifacts to output_dir using the existing exporter classes.
+    Also generates sync XML in memory and returns it as a string for the preview panel.
+    """
+    finished = Signal(str, str, str)   # output_dir, xml_text, post_json_text
+    error = Signal(str)
+    log_msg = Signal(str)
+
+    def __init__(self, seq_data: dict, sync_data: dict, hw,
+                 output_dir: str):
+        super().__init__()
+        self.seq_data = seq_data
+        self.sync_data = sync_data
+        self.hw = hw
+        self.output_dir = output_dir
+
+    def run(self):
+        import json as _json
+        try:
+            os.makedirs(self.output_dir, exist_ok=True)
+
+            xml_path = os.path.join(self.output_dir, "sync_v2.xml")
+            self.log_msg.emit("Generating sync_v2.xml…")
+            xml_gen = XMLGenerator()
+            adc_values, adc_starts = xml_gen.generate(
+                self.sync_data, xml_path, self.hw
+            )
+            with open(xml_path, "r", encoding="utf-8") as fh:
+                xml_text = fh.read()
+
+            self.log_msg.emit("Exporting RF…")
+            RFExporter().export(
+                self.seq_data,
+                self.seq_data.get("params", {}),
+                self.output_dir,
+            )
+
+            if all(k in self.seq_data
+                   for k in ["gx", "gy", "gz", "t_gx", "t_gy", "t_gz"]):
+                self.log_msg.emit("Exporting gradients…")
+                GradientExporter().export(
+                    self.seq_data,
+                    self.seq_data.get("params", {}),
+                    self.output_dir,
+                )
+
+            self.log_msg.emit("Exporting PicoScope params…")
+            PicoScopeExporter().generate(
+                adc_values, adc_starts, self.output_dir, self.hw
+            )
+
+            # Build a minimal POST manifest for preview (no network call)
+            import datetime
+            post_payload = {
+                "info": {
+                    "infostr": os.path.basename(
+                        self.seq_data.get("params", {}).get("seq_path", "sequence")
+                    ),
+                    "engine": "DefaultEngine",
+                    "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
+                    "isync": {"file": "sync_v2.xml"},
+                    "isdr": {
+                        "file": f"rf_{self.seq_data.get('params', {}).get('rf_raster_time', self.hw.rf_raster_time)}_raster.bin"
+                    },
+                    "iadc": {
+                        "sample_freq": self.hw.adc_raster_time and (1 / self.hw.adc_raster_time),
+                        "points": adc_values,
+                        "times": adc_starts,
+                    },
+                    "igrax": {
+                        "file": "gx.txt",
+                        "enabled": "gx" in self.seq_data and len(self.seq_data["gx"]) > 0,
+                    },
+                    "igray": {
+                        "file": "gy.txt",
+                        "enabled": "gy" in self.seq_data and len(self.seq_data["gy"]) > 0,
+                    },
+                    "igraz": {
+                        "file": "gz.txt",
+                        "enabled": "gz" in self.seq_data and len(self.seq_data["gz"]) > 0,
+                    },
+                }
+            }
+            post_text = _json.dumps(post_payload, indent=2, default=str)
+
+            self.log_msg.emit(f"Export complete → {self.output_dir}")
+            self.finished.emit(self.output_dir, xml_text, post_text)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+class XmlPreviewWorker(QThread):
+    """
+    Generate sync XML in a temp file and return its text without writing to output_dir.
+    Used to populate the XML preview panel after interpretation, before export.
+    """
+    finished = Signal(str, str)   # xml_text, post_json_text (minimal)
+    error = Signal(str)
+
+    def __init__(self, sync_data: dict, hw):
+        super().__init__()
+        self.sync_data = sync_data
+        self.hw = hw
+
+    def run(self):
+        import json as _json, datetime
+        try:
+            with tempfile.NamedTemporaryFile(suffix=".xml", delete=False,
+                                             mode="w", encoding="utf-8") as tf:
+                tmp_path = tf.name
+            try:
+                xml_gen = XMLGenerator()
+                adc_values, adc_starts = xml_gen.generate(
+                    self.sync_data, tmp_path, self.hw
+                )
+                with open(tmp_path, "r", encoding="utf-8") as fh:
+                    xml_text = fh.read()
+            finally:
+                try:
+                    os.unlink(tmp_path)
+                except OSError:
+                    pass
+
+            post_payload = {
+                "note": "Preview only — run Export to write actual files",
+                "isync_blocks": self.sync_data.get("number_of_blocks"),
+                "adc_windows": len(adc_values),
+                "adc_durations_s": [float(v) for v in adc_values],
+                "adc_starts_s": [float(v) for v in adc_starts],
+                "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
+            }
+            post_text = _json.dumps(post_payload, indent=2)
+
+            self.finished.emit(xml_text, post_text)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+class OrchestratorWorker(QThread):
+    """
+    Generic off-thread caller for OrchestratorClient methods.
+
+    Usage:
+        worker = OrchestratorWorker(client.run_all, job_id)
+        worker.finished.connect(lambda result: ...)
+        worker.error.connect(lambda msg: ...)
+        worker.start()
+    """
+    finished = Signal(object)
+    error    = Signal(str)
+    log_msg  = Signal(str)
+
+    def __init__(self, fn, *args, **kwargs) -> None:
+        super().__init__()
+        self._fn = fn
+        self._args = args
+        self._kwargs = kwargs
+
+    def run(self) -> None:
+        try:
+            result = self._fn(*self._args, **self._kwargs)
+            self.finished.emit(result)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+class SeqInterpHttpWorker(QThread):
+    """
+    Upload a .seq file to the seq_interp service, poll until done,
+    and return the full result dict.
+
+    Signals:
+        finished(dict)  — interpretation result from GET /result/{task_id}
+        error(str)      — error message
+        progress(str)   — log / status messages during polling
+    """
+    finished = Signal(dict)
+    error    = Signal(str)
+    progress = Signal(str)
+
+    def __init__(self, client, seq_file_path: str) -> None:
+        """
+        client: SeqInterpClient instance
+        seq_file_path: local path to the .seq file to upload
+        """
+        super().__init__()
+        self._client = client
+        self._seq_file_path = seq_file_path
+
+    def run(self) -> None:
+        from src.clients.seq_interp_client import SeqInterpError
+        try:
+            result = self._client.interpret_and_wait(
+                self._seq_file_path,
+                progress_cb=self.progress.emit,
+            )
+            self.finished.emit(result)
+        except SeqInterpError as exc:
+            self.error.emit(f"SeqInterpError: {exc}")
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")

+ 0 - 0
apps/gui/src/hardware/__init__.py


+ 65 - 0
apps/gui/src/hardware/constraints.py

@@ -0,0 +1,65 @@
+import json
+
+
+class HardwareConstraints:
+    """
+    Класс, хранящий аппаратные ограничения и настройки MRI-системы.
+    Может инициализироваться из JSON-файла.
+    """
+
+    def __init__(self, json_path: str = None):
+        # Значения по умолчанию (совместимые с pypulseq)
+        self.rf_raster_time = 1e-6  # сек, шаг временной дискретизации RF
+        self.grad_raster_time = 10e-6  # сек, шаг дискретизации градиента
+        self.adc_raster_time = 100e-9  # сек, шаг дискретизации АЦП
+        self.block_duration_raster = 10e-6  # сек, шаг дискретизации длительности блока
+
+        # Системные ограничения (по умолчанию отсутствуют задержки мертвого времени)
+        self.rf_dead_time = 0.0
+        self.rf_ringdown_time = 0.0
+        self.adc_dead_time = 0.0
+        self.gamma = 42.576e6  # Гиромагнитное отношение (Гц/Т) для протона
+
+        # Кастомные параметры системы
+        self.TR_DELAY = 20e-9  # сек, задержка после съема (между TR)
+        self.RF_DELAY = 500e-6  # сек, задержка перед RF-импульсом
+        self.START_DELAY = 17e-6  # сек, начальная задержка перед последовательностью
+        self.MIN_BLOCK_DURATION = 20e-9  # сек, минимальная длительность блока (квант времени последовательности)
+        self.GRAD_DELAY = 1000e-9
+
+        # Максимальные амплитуды
+        self.RF_MAX = 1.0  # относительная макс. амплитуда RF (нормирована на 1.0)
+        self.GRAD_MAX = 9e-3 * self.gamma  # макс. градиент (Гц/м) по умолчанию 9 mT/m * gamma
+
+        # Загрузка параметров из JSON при указании пути
+        if json_path:
+            self.load_from_json(json_path)
+
+    def load_from_json(self, json_path: str):
+        """
+        Загружает параметры аппаратных ограничений из JSON-файла.
+        """
+        with open(json_path, 'r') as f:
+            data = json.load(f)
+        # Обновление обязательных параметров (если указаны в файле)
+        self.rf_raster_time = data.get("rf_raster_time", self.rf_raster_time)
+        self.grad_raster_time = data.get("grad_raster_time", self.grad_raster_time)
+        self.adc_raster_time = data.get("adc_raster_time", self.adc_raster_time)
+        self.block_duration_raster = data.get("block_duration_raster", self.block_duration_raster)
+        self.rf_dead_time = data.get("rf_dead_time", self.rf_dead_time)
+        self.rf_ringdown_time = data.get("rf_ringdown_time", self.rf_ringdown_time)
+        self.adc_dead_time = data.get("adc_dead_time", self.adc_dead_time)
+        self.gamma = data.get("gamma", self.gamma)
+        # Обновление пользовательских параметров
+        self.TR_DELAY = data.get("TR_DELAY", self.TR_DELAY)
+        self.RF_DELAY = data.get("RF_DELAY", self.RF_DELAY)
+        if self.rf_raster_time == 0.5e-6:
+            self.START_DELAY = 885 * self.MIN_BLOCK_DURATION
+        elif self.rf_raster_time == 0.05e-6:
+            self.START_DELAY = 89 * self.MIN_BLOCK_DURATION
+        else:
+            self.START_DELAY = self.MIN_BLOCK_DURATION * 10
+        self.MIN_BLOCK_DURATION = data.get("MIN_BLOCK_DURATION", self.MIN_BLOCK_DURATION)
+        # Обновление максимальных амплитуд (если указаны)
+        self.RF_MAX = data.get("RF_MAX", self.RF_MAX)
+        self.GRAD_MAX = data.get("GRAD_MAX", self.GRAD_MAX)

+ 0 - 0
apps/gui/src/interfaces/__init__.py


+ 44 - 0
apps/gui/src/interfaces/gradient_exporter.py

@@ -0,0 +1,44 @@
+import numpy as np
+
+
+class GradientExporter:
+    """
+    Экспорт градиентных сигналов в формате test1_full/srv_interp.py
+    """
+
+    @staticmethod
+    def _duplicates_delete(loc_list):
+        new_list = [[0] * 2]
+        for i in range(len(loc_list)):
+            if loc_list[i][0] not in np.transpose(new_list)[0]:
+                new_list.append(loc_list[i])
+        return new_list
+
+    @staticmethod
+    def _gradient_time_convertation(params: dict, time_sample):
+        g_raster_time = params["grad_raster_time"]
+        return time_sample / g_raster_time
+
+    @staticmethod
+    def _gradient_ampl_convertation(params: dict, gradient_herz):
+        amplitude_max = params["G_amp_max"]
+        amplitude_raster = 32767
+        step_hz_m = amplitude_max / amplitude_raster
+        return gradient_herz / step_hz_m * 1000
+
+    def export(self, waveforms: dict, params: dict, path: str) -> None:
+        loc_t_gx = self._gradient_time_convertation(params, waveforms["t_gx"])
+        loc_t_gy = self._gradient_time_convertation(params, waveforms["t_gy"])
+        loc_t_gz = self._gradient_time_convertation(params, waveforms["t_gz"])
+
+        loc_gx = self._gradient_ampl_convertation(params, waveforms["gx"])
+        loc_gy = self._gradient_ampl_convertation(params, waveforms["gy"])
+        loc_gz = self._gradient_ampl_convertation(params, waveforms["gz"])
+
+        gx_out = self._duplicates_delete(np.transpose([loc_t_gx, loc_gx]))
+        gy_out = self._duplicates_delete(np.transpose([loc_t_gy, loc_gy]))
+        gz_out = self._duplicates_delete(np.transpose([loc_t_gz, loc_gz]))
+
+        np.savetxt(f"{path}/gx.txt", gx_out, fmt="%10.0f")
+        np.savetxt(f"{path}/gy.txt", gy_out, fmt="%10.0f")
+        np.savetxt(f"{path}/gz.txt", gz_out, fmt="%10.0f")

+ 105 - 0
apps/gui/src/interfaces/nnet_exporter.py

@@ -0,0 +1,105 @@
+import numpy as np
+
+
+class NNetExporter:
+    """
+    Генератор конфигурационного файла для PicoScope.
+    Создает XML с параметрами точек съема ADC для PicoScope.
+    """
+    def export_to_nn(self, seq) :
+        grad_signal = [
+            np.empty([0]),
+            np.empty([0]),
+            np.empty([0]),
+        ]
+        grad_time = [
+            np.empty([0]),
+            np.empty([0]),
+            np.empty([0]),
+        ]
+        rf_signal = np.empty([0])
+        rf_time = np.empty([0])
+        time_disp: str = "s"
+        valid_time_units = ["s", "ms", "us"]
+        t_factor_list = [1, 1e3, 1e6]
+        t_factor = t_factor_list[valid_time_units.index(time_disp)]
+        valid_grad_units = ["kHz/m", "mT/m"]
+        grad_disp: str = "kHz/m"
+        g_factor_list = [1e-3, 1e3 / seq.system.gamma]
+        g_factor = g_factor_list[valid_grad_units.index(grad_disp)]
+        t0 = 0
+        time_range = (0, np.inf)
+        current_sequence = seq
+        for block_counter in current_sequence.block_events:
+            block = current_sequence.get_block(block_counter)
+            is_valid = (time_range[0] <= t0 + seq.block_durations[block_counter]
+                        and t0 <= time_range[1])
+            grad_channels = ["gx", "gy", "gz"]
+            if is_valid:
+                for x in range(len(grad_channels)):  # Gradients
+                    if getattr(block, grad_channels[x], None) is not None:
+                        grad = getattr(block, grad_channels[x])
+                        if grad.type == "grad":
+                            # We extend the shape by adding the first and the last points in an effort of making the
+                            # display a bit less confusing...
+                            time = grad.delay + np.array([0, *grad.tt, grad.shape_dur])
+                            waveform = g_factor * np.array(
+                                (grad.first, *grad.waveform, grad.last)
+                            )
+                        else:
+                            time = np.array(cumsum(
+                                0,
+                                grad.delay,
+                                grad.rise_time,
+                                grad.flat_time,
+                                grad.fall_time,
+                            ))
+                            waveform = (
+                                    g_factor * grad.amplitude * np.array([0, 0, 1, 1, 0])
+                            )
+
+                        grad_time[x] = np.concatenate((grad_time[x], t_factor * (t0 + time)), axis=None)
+                        grad_signal[x] = np.concatenate((grad_signal[x], waveform), axis=None)
+
+                        t0 += seq.block_durations[block_counter]
+                if getattr(block, "rf", None) is not None:  # RF
+                    rf = block.rf
+                    tc, ic = pp.calc_rf_center(rf)
+                    time = rf.t
+                    signal = rf.signal
+                    if abs(signal[0]) != 0:
+                        signal = np.concatenate(([0], signal))
+                        time = np.concatenate(([time[0]], time))
+                        ic += 1
+
+                    if abs(signal[-1]) != 0:
+                        signal = np.concatenate((signal, [0]))
+                        time = np.concatenate((time, [time[-1]]))
+
+                    rf_signal = np.concatenate((rf_signal, np.abs(signal)), axis=None)
+                    rf_time = np.concatenate((rf_time, t_factor * (t0 + time + rf.delay)), axis=None)
+
+        # plt.plot(grad_time[0][0:50], grad_signal[0][0:50])
+        # plt.plot(grad_time[1][0:50], grad_signal[1][0:50])
+        # plt.plot(grad_time[2][0:50], grad_signal[2][0:50])
+        # plt.show()
+
+        start_time = 0
+        end_time = grad_time[0][-1]
+        time_step = abs(dict["t_rf"][1] - dict["t_rf"][0])  # Растр (шаг) времени
+
+        # Создаём равномерную временную сетку
+        time_array = np.arange(start_time, end_time + time_step, time_step)
+
+        # Интерполируем значения амплитуды на этой сетке
+        amplitude_array = np.interp(time_array, grad_time[0], grad_signal[0])
+        plt.plot(time_array, amplitude_array, label="Gx")
+        amplitude_array = np.interp(time_array, grad_time[1], grad_signal[1])
+        plt.plot(time_array, amplitude_array, label="Gy")
+        amplitude_array = np.interp(time_array, grad_time[2], grad_signal[2])
+        plt.plot(time_array, amplitude_array, label="Gz")
+        plt.plot(rf_time, rf_signal)
+        plt.legend()
+        plt.xlim((0, 0.02))
+        # plt.plot(dict["t_rf"][0:7000], np.abs(dict["rf"])[0:7000])
+        plt.show()

+ 48 - 0
apps/gui/src/interfaces/picoscope_exporter.py

@@ -0,0 +1,48 @@
+from yattag import Doc, indent
+
+
+class PicoScopeExporter:
+    """
+    Генератор picoscope_params.xml в формате test1_full/srv_interp.py
+    """
+
+    def generate(
+        self,
+        adc_values,
+        adc_starts,
+        path: str,
+        hw,
+        sampling_freq: float = 8e6,
+        num_channels: int = 3,
+    ):
+        adc_out_timings = [int(i * sampling_freq) for i in adc_values]
+
+        doc, tag, text, line = Doc().ttl()
+        with tag("root"):
+            with tag("points"):
+                with tag("title"):
+                    text("Points")
+                with tag("value"):
+                    text(str(adc_out_timings))
+
+            with tag("num_of_channels"):
+                with tag("title"):
+                    text("Number of Channels")
+                with tag("value"):
+                    text(num_channels)
+
+            with tag("times"):
+                with tag("title"):
+                    text("Times")
+                with tag("value"):
+                    text(str([float(i) for i in adc_starts]))
+
+            with tag("sample_freq"):
+                with tag("title"):
+                    text("Sample Frequency")
+                with tag("value"):
+                    text(sampling_freq)
+
+        xml_string = indent(doc.getvalue(), indentation=" " * 4, newline="\r")
+        with open(f"{path}/picoscope_params.xml", "w", encoding="utf-8") as f:
+            f.write(xml_string)

+ 141 - 0
apps/gui/src/interfaces/post_request_generator.py

@@ -0,0 +1,141 @@
+import json
+import logging
+import os
+from datetime import datetime
+from pathlib import Path
+
+logger = logging.getLogger("MRISequenceInterpreter")
+
+
+class PostRequestGenerator:
+    """
+    Генератор POST-запроса для аппаратного сервиса.
+
+    Формирует JSON-манифест в формате, совместимом с тестовой машиной
+    (см. POST_request_mes.txt), подставляя реальные имена выходных файлов
+    интерпретатора. При наличии hw_service_url отправляет запрос асинхронно.
+    """
+
+    # ------------------------------------------------------------------
+    # Public interface
+    # ------------------------------------------------------------------
+
+    def build(
+        self,
+        seq_data: dict,
+        adc_values: list,
+        sequence_path: str,
+        output_dir: str,
+        hw_cfg: dict,
+        rf_raster_time: float,
+    ) -> dict:
+        """
+        Строит словарь POST-запроса.
+
+        Parameters
+        ----------
+        seq_data        : выходной словарь PulseqLoader (для определения наличия градиентов)
+        adc_values      : длительности ADC-окон в секундах (из xml_generator)
+        sequence_path   : путь к исходному .seq файлу
+        output_dir      : директория с выходными артефактами
+        hw_cfg          : содержимое hw_config.json
+        rf_raster_time  : шаг RF-растра в секундах (из params["rf_raster_time"])
+        """
+        prefix = hw_cfg.get("file_path_prefix", "")
+
+        iadc_cfg = dict(hw_cfg.get("iadc", {}))
+        srate = iadc_cfg.get("srate", 8_000_000)
+        iadc_cfg["points"] = [int(v * srate) for v in adc_values]
+
+        isync_cfg = dict(hw_cfg.get("isync", {}))
+        isync_cfg["file"] = prefix + "sync_v2.xml"
+
+        isdr_cfg = dict(hw_cfg.get("isdr", {}))
+        isdr_cfg["file"] = prefix + f"rf_{rf_raster_time}_raster.bin"
+
+        igrax_cfg = dict(hw_cfg.get("igrax", {}))
+        igray_cfg = dict(hw_cfg.get("igray", {}))
+        igraz_cfg = dict(hw_cfg.get("igraz", {}))
+
+        # Включаем ось, если в seq_data присутствует соответствующий сигнал
+        igrax_cfg["file"] = prefix + "gx.txt"
+        igray_cfg["file"] = prefix + "gy.txt"
+        igraz_cfg["file"] = prefix + "gz.txt"
+
+        if "gx" in seq_data:
+            igrax_cfg["enabled"] = True
+        if "gy" in seq_data:
+            igray_cfg["enabled"] = True
+        if "gz" in seq_data:
+            igraz_cfg["enabled"] = True
+
+        infostr = Path(sequence_path).stem
+
+        payload = {
+            "info": {
+                "infostr": infostr,
+                "engine": "DefaultEngine",
+                "time": datetime.now().strftime("%Y-%m-%d %H:%M"),
+                "iadc": iadc_cfg,
+                "isync": isync_cfg,
+                "isdr": isdr_cfg,
+                "igrax": igrax_cfg,
+                "igray": igray_cfg,
+                "igraz": igraz_cfg,
+            }
+        }
+        return payload
+
+    def write(self, payload: dict, output_dir: str) -> str:
+        """
+        Сохраняет payload в <output_dir>/post_request.json.
+        Возвращает абсолютный путь к файлу.
+        """
+        os.makedirs(output_dir, exist_ok=True)
+        out_path = os.path.join(output_dir, "post_request.json")
+        with open(out_path, "w", encoding="utf-8") as f:
+            json.dump(payload, f, indent=4)
+        logger.info("POST-запрос сохранён: %s", out_path)
+        return out_path
+
+    async def send(self, payload: dict, url: str) -> int:
+        """
+        Асинхронно отправляет payload как JSON POST на url.
+        Возвращает HTTP-статус ответа (или 0 при ошибке соединения).
+        Ошибки логируются, но не прерывают интерпретацию.
+        """
+        try:
+            import httpx
+            async with httpx.AsyncClient(timeout=10.0) as client:
+                response = await client.post(url, json=payload)
+            logger.info(
+                "POST отправлен на %s — статус: %d", url, response.status_code
+            )
+            return response.status_code
+        except ImportError:
+            # Резервный вариант: синхронный requests через поток
+            return await self._send_via_requests(payload, url)
+        except Exception as exc:
+            logger.warning("Не удалось отправить POST на %s: %s", url, exc)
+            return 0
+
+    # ------------------------------------------------------------------
+    # Private helpers
+    # ------------------------------------------------------------------
+
+    @staticmethod
+    async def _send_via_requests(payload: dict, url: str) -> int:
+        import asyncio
+        try:
+            import requests as _requests
+
+            def _do_post():
+                resp = _requests.post(url, json=payload, timeout=10)
+                return resp.status_code
+
+            status = await asyncio.to_thread(_do_post)
+            logger.info("POST отправлен на %s — статус: %d", url, status)
+            return status
+        except Exception as exc:
+            logger.warning("Не удалось отправить POST на %s: %s", url, exc)
+            return 0

+ 92 - 0
apps/gui/src/interfaces/pulseq_adapter.py

@@ -0,0 +1,92 @@
+import json
+import logging
+import os
+
+from LF_scanner.pypulseq import Sequence
+
+from src.hardware.constraints import HardwareConstraints
+
+
+class PulseqLoader:
+    """
+    Адаптер для загрузки последовательностей в формате Pulseq (.seq файлы).
+    """
+
+    def __init__(self, hw: HardwareConstraints):
+        self.hw = hw
+        self.logger = logging.getLogger("PulseqLoader")
+
+    def load(self, path: str) -> dict:
+        """
+        Читает Pulseq-файл и возвращает структуру данных последовательности.
+        """
+        seq = Sequence(system=self.hw)
+        seq.read(path)
+
+        blocks = self._parse_blocks(seq)
+        waveforms = seq.waveforms_export()
+        params = self._load_params(path)
+
+        seq_data = {
+            "blocks": blocks,
+            "sequence": seq,
+            "params": params,
+        }
+        seq_data.update(waveforms)
+
+        self.logger.info(
+            "Файл %s загружен. Осталось %d блоков после удаления нулевой длительности.",
+            path,
+            len(blocks),
+        )
+        return seq_data
+
+    def _load_params(self, seq_path: str) -> dict:
+        json_path = os.path.splitext(seq_path)[0] + ".json"
+        defaults = {
+            "G_amp_max": self.hw.GRAD_MAX,
+            "grad_raster_time": self.hw.grad_raster_time,
+            "rf_raster_time": self.hw.rf_raster_time,
+            "gamma": self.hw.gamma,
+            "scale_rf": 1.0,
+        }
+        if not os.path.exists(json_path):
+            return defaults
+
+        with open(json_path, "r", encoding="utf-8") as f:
+            loaded = json.load(f)
+        defaults.update(loaded)
+        return defaults
+
+    def _parse_blocks(self, seq) -> list:
+        """
+        Формирует список блоков из объекта Sequence.
+        """
+        blocks = []
+        for block_id in seq.block_events:
+            block = seq.get_block(block_id)
+            duration = seq.block_durations[block_id]
+            if duration == 0:
+                self.logger.warning(
+                    "Удалён блок (ID: %d) с нулевой длительностью.",
+                    block_id,
+                )
+                continue
+
+            block_type = []
+            has_adc = False
+            if getattr(block, "rf", None) is not None:
+                block_type.append("RF")
+            if getattr(block, "adc", None) is not None:
+                block_type.append("ADC")
+                has_adc = True
+            if any(getattr(block, axis, None) is not None for axis in ("gx", "gy", "gz")):
+                block_type.append("GRAD")
+
+            blocks.append({
+                "type": block_type,
+                "duration": duration,
+                "has_adc": has_adc,
+            })
+
+        return blocks

+ 57 - 0
apps/gui/src/interfaces/rf_exporter.py

@@ -0,0 +1,57 @@
+import numpy as np
+
+
+class RFExporter:
+    """
+    Экспорт RF в формате test1_full/srv_interp.py
+    """
+
+    @staticmethod
+    def _radio_ampl_convertation(rf_ampl, t_rf, rf_raster_local):
+        out_rf_list = []
+        rf_ampl_raster = 127
+        rf_ampl_maximum = np.abs(max(rf_ampl)) if len(rf_ampl) else 0
+        if rf_ampl_maximum == 0:
+            return out_rf_list
+
+        proportional_cf_rf = rf_ampl_raster / rf_ampl_maximum
+        num_zeroes = 0
+        for rf_iter in range(len(rf_ampl) - 1):
+            if abs(t_rf[rf_iter] - t_rf[rf_iter + 1]) > 2 * rf_raster_local:
+                num_zeroes += int(np.abs((t_rf[rf_iter] - t_rf[rf_iter + 1]) / rf_raster_local))
+            else:
+                out_rf_list += [0] * num_zeroes
+                num_zeroes = 0
+                out_rf_list.append(round(rf_ampl[rf_iter].real * proportional_cf_rf))
+                out_rf_list.append(round(rf_ampl[rf_iter].imag * proportional_cf_rf))
+        return out_rf_list
+
+    def export(self, waveforms: dict, params: dict, output_dir: str):
+        rf_raster_local = params["rf_raster_time"]
+
+        if rf_raster_local == 5e-7:
+            empty_block_time_delay = 17.7e-6
+        elif rf_raster_local == 2.5e-7:
+            empty_block_time_delay = 3.6e-6
+        elif rf_raster_local == 5e-8:
+            empty_block_time_delay = 1.77e-6
+        else:
+            empty_block_time_delay = 0
+
+        rf_out = [0] * int(2 * (empty_block_time_delay // rf_raster_local))
+        rf_out += self._radio_ampl_convertation(
+            waveforms["rf"],
+            waveforms["t_rf"],
+            rf_raster_local,
+        )
+
+        scale_rf = params.get("scale_rf", 1.0)
+        rf_out = [round(x * scale_rf) for x in rf_out]
+
+        file_path = f"{output_dir}/rf_{rf_raster_local}_raster.bin"
+        with open(file_path, "wb") as file_rf:
+            for byte in rf_out:
+                file_rf.write(int(byte).to_bytes(1, byteorder="big", signed=True))
+
+        np.savetxt(f"{output_dir}/rf_time.txt", np.transpose(waveforms["t_rf"]))
+        np.savetxt(f"{output_dir}/rf_ampl.txt", np.transpose(waveforms["rf"]))

+ 69 - 0
apps/gui/src/interfaces/xml_generator.py

@@ -0,0 +1,69 @@
+from yattag import Doc, indent
+
+
+class XMLGenerator:
+    """
+    Генератор XML синхронизации в формате, совместимом с test1_full/srv_interp.py
+    """
+
+    def generate(self, sync_data: dict, path: str, hw):
+        number_of_blocks = sync_data["number_of_blocks"]
+        gate_rf = sync_data["gate_rf"]
+        gate_tr_switch = sync_data["gate_tr_switch"]
+        gate_adc = sync_data["gate_adc"]
+        blocks_duration = sync_data["blocks_duration"]
+        synchro_block_timer = sync_data["synchro_block_timer"]
+        min_block_time = sync_data["min_block_time"]
+
+        doc, tag, text = Doc().tagtext()
+
+        with tag("root"):
+            with tag("ParamCount"):
+                text(number_of_blocks + 1)
+
+            adc_times_values = []
+            adc_times_starts = []
+
+            with tag("RF"):
+                with tag("RF1"):
+                    text(0)
+                for rf_iter in range(number_of_blocks):
+                    with tag("RF" + str(rf_iter + 2)):
+                        text(gate_rf[rf_iter])
+
+            with tag("SW"):
+                with tag("SW1"):
+                    text(1)
+                for sw_iter in range(number_of_blocks):
+                    with tag("SW" + str(sw_iter + 2)):
+                        text(gate_tr_switch[sw_iter])
+
+            with tag("ADC"):
+                with tag("ADC1"):
+                    text(0)
+                for adc_iter in range(number_of_blocks):
+                    if gate_adc[adc_iter] == 1:
+                        adc_times_values.append(blocks_duration[adc_iter])
+                        adc_times_starts.append(sum(blocks_duration[0:adc_iter]))
+                    with tag("ADC" + str(adc_iter + 2)):
+                        text(gate_adc[adc_iter])
+
+            with tag("GR"):
+                with tag("GR1"):
+                    text(1)
+                for gr_iter in range(number_of_blocks):
+                    with tag("GR" + str(gr_iter + 2)):
+                        text(0)
+
+            with tag("CL"):
+                with tag("CL1"):
+                    text(int(min_block_time / synchro_block_timer))
+                for cl_iter in range(number_of_blocks):
+                    with tag("CL" + str(cl_iter + 2)):
+                        text(int(blocks_duration[cl_iter] / synchro_block_timer))
+
+        xml_string = indent(doc.getvalue(), indentation=" " * 4, newline="\r")
+        with open(path, "w", encoding="utf-8") as f:
+            f.write(xml_string)
+
+        return adc_times_values, adc_times_starts

+ 50 - 0
apps/gui/src/server_worker.py

@@ -0,0 +1,50 @@
+"""
+QThread wrapper for the uvicorn/FastAPI server.
+
+Usage:
+    worker = ServerWorker(host="0.0.0.0", port=7475)
+    worker.started_ok.connect(lambda url: ...)
+    worker.error.connect(lambda msg: ...)
+    worker.start()
+    ...
+    worker.stop()
+"""
+from __future__ import annotations
+
+import asyncio
+
+from PySide6.QtCore import QThread, Signal
+
+
+class ServerWorker(QThread):
+    started_ok = Signal(str)   # "http://<host>:<port>"
+    error = Signal(str)
+
+    def __init__(self, host: str = "0.0.0.0", port: int = 7475,
+                 parent=None) -> None:
+        super().__init__(parent)
+        self._host = host
+        self._port = port
+        self._server = None
+
+    def run(self) -> None:
+        try:
+            import uvicorn
+            from api import app   # imported inside lf_mri_gui/
+
+            loop = asyncio.new_event_loop()
+            asyncio.set_event_loop(loop)
+
+            cfg = uvicorn.Config(app, host=self._host, port=self._port,
+                                 log_level="warning")
+            self._server = uvicorn.Server(cfg)
+            self.started_ok.emit(f"http://{self._host}:{self._port}")
+            loop.run_until_complete(self._server.serve())
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+        finally:
+            self._server = None
+
+    def stop(self) -> None:
+        if self._server is not None:
+            self._server.should_exit = True

+ 0 - 0
apps/gui/src/tabs/__init__.py


+ 409 - 0
apps/gui/src/tabs/fid_tab.py

@@ -0,0 +1,409 @@
+"""
+FID Sequence tab — PySide6 port of seq_interp/src/fid_gui/gui_RF_adj(FID).py.
+
+Generates a Free Induction Decay (FID) pulse sequence via seqgen_FID,
+visualises it with the shared PlotPanel, and emits fid_seq_generated(path)
+so LFMRIWindow can hand the .seq off to SeqInterpTab.
+"""
+from __future__ import annotations
+
+import json
+import os
+import tempfile
+from datetime import datetime
+from math import ceil
+from types import SimpleNamespace
+
+import numpy as np
+from PySide6.QtCore import Qt, QThread, Signal
+from PySide6.QtGui import QFont
+from PySide6.QtWidgets import (
+    QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
+    QGroupBox, QFormLayout, QLabel, QDoubleSpinBox, QSpinBox,
+    QPushButton, QRadioButton, QButtonGroup, QScrollArea,
+    QSizePolicy, QFileDialog, QMessageBox, QProgressBar,
+    QFrame,
+)
+
+from src.gui.plot_panel import PlotPanel
+from src.gui.workers import LoadInterpWorker
+
+_GAMMA = 42.576e6   # Hz/T, proton
+_G_AMP_MAX_MT_M = 5.0
+_G_SLEW_MAX_T_M_S = 45.0
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+class _FidWorker(QThread):
+    """Generates a FID sequence and saves it to a .seq file off the UI thread."""
+    finished = Signal(str)   # path to saved .seq
+    error = Signal(str)
+    log_msg = Signal(str)
+
+    def __init__(self, param: SimpleNamespace, seq_path: str) -> None:
+        super().__init__()
+        self._param = param
+        self._seq_path = seq_path
+
+    def run(self) -> None:
+        try:
+            from src.fid.seqgen_FID import seqgen_FID
+            self.log_msg.emit("Generating FID sequence…")
+            seq = seqgen_FID(self._param)
+            seq.write(self._seq_path)
+            self.log_msg.emit(f"Sequence saved: {self._seq_path}")
+            self.finished.emit(self._seq_path)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+class FidTab(QWidget):
+    """FID pulse-sequence editor and visualiser."""
+
+    fid_seq_generated = Signal(str)   # emitted when user clicks "Load in Sequence Tab"
+
+    # ── defaults matching original Tkinter GUI ────────────────────────────
+    _D = {
+        "BW_per_point": 100.0,   # Hz
+        "N_point":       512,
+        "TR":            200.0,  # ms  (converted to s when building param)
+        "NA":            1,
+        "freq_offset":   0.0,    # Hz
+        "t_ex":          1.0,    # ms  (converted to s)
+        "rf_adc_delay":  0.1,    # ms  (rf_ringdown_time, converted to s via *1e-3)
+        "scale_rf":      1.0,
+    }
+
+    def __init__(
+        self,
+        hw_config_path: str | None = None,
+        output_dir: str | None = None,
+        parent: QWidget | None = None,
+    ) -> None:
+        super().__init__(parent)
+        self._hw_config_path = hw_config_path
+        self._output_dir = output_dir
+        self._last_seq_path: str | None = None
+        self._last_param: SimpleNamespace | None = None
+        self._gen_worker: QThread | None = None
+        self._interp_worker: QThread | None = None
+
+        root = QVBoxLayout(self)
+        root.setContentsMargins(0, 0, 0, 0)
+        root.setSpacing(0)
+        root.addWidget(self._build_toolbar())
+        root.addWidget(self._build_splitter(), stretch=1)
+        root.addWidget(self._build_status_bar())
+
+    # ================================================================== #
+    #  Public API                                                          #
+    # ================================================================== #
+
+    def set_hw_config(self, path: str) -> None:
+        self._hw_config_path = path
+
+    def set_output_dir(self, path: str) -> None:
+        self._output_dir = path
+
+    # ================================================================== #
+    #  Toolbar                                                             #
+    # ================================================================== #
+
+    def _build_toolbar(self) -> QWidget:
+        bar = QWidget()
+        bar.setObjectName("FidToolBar")
+        bar.setStyleSheet(
+            "#FidToolBar { background: palette(window); border-bottom: 1px solid palette(mid); }"
+        )
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(6, 4, 6, 4)
+        lay.setSpacing(4)
+
+        def btn(label: str, tip: str, slot=None, enabled: bool = True) -> QPushButton:
+            b = QPushButton(label)
+            b.setToolTip(tip)
+            b.setEnabled(enabled)
+            b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+            if slot:
+                b.clicked.connect(slot)
+            lay.addWidget(b)
+            return b
+
+        def sep() -> QFrame:
+            f = QFrame()
+            f.setFrameShape(QFrame.VLine)
+            f.setFrameShadow(QFrame.Sunken)
+            f.setFixedWidth(2)
+            return f
+
+        self._btn_generate = btn("▶ Generate", "Generate FID sequence and visualise", self._generate)
+        lay.addWidget(sep())
+        self._btn_save = btn("💾 Save .seq", "Save .seq + .json to output directory",
+                             self._save, enabled=False)
+        self._btn_load_seq_tab = btn("➡ Load in Sequence Tab",
+                                     "Send this .seq to the Sequence interpreter tab",
+                                     self._load_in_seq_tab, enabled=False)
+        lay.addStretch()
+
+        self._progress = QProgressBar()
+        self._progress.setRange(0, 0)
+        self._progress.setFixedWidth(120)
+        self._progress.setVisible(False)
+        lay.addWidget(self._progress)
+
+        return bar
+
+    # ================================================================== #
+    #  Main splitter                                                        #
+    # ================================================================== #
+
+    def _build_splitter(self) -> QSplitter:
+        split = QSplitter(Qt.Horizontal)
+        split.addWidget(self._build_left_panel())
+
+        self._plots = PlotPanel()
+        split.addWidget(self._plots)
+        split.setSizes([320, 900])
+        return split
+
+    def _build_left_panel(self) -> QScrollArea:
+        inner = QWidget()
+        inner.setMinimumWidth(240)
+        inner.setMaximumWidth(400)
+        lay = QVBoxLayout(inner)
+        lay.setContentsMargins(6, 6, 6, 6)
+        lay.setSpacing(8)
+
+        # ── parameter form ────────────────────────────────────────────
+        param_grp = QGroupBox("FID Parameters")
+        form = QFormLayout(param_grp)
+        form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+
+        def dbl(val, mn, mx, step, dec=3) -> QDoubleSpinBox:
+            sb = QDoubleSpinBox()
+            sb.setRange(mn, mx)
+            sb.setSingleStep(step)
+            sb.setDecimals(dec)
+            sb.setValue(val)
+            return sb
+
+        def integer(val, mn, mx) -> QSpinBox:
+            sb = QSpinBox()
+            sb.setRange(mn, mx)
+            sb.setValue(val)
+            return sb
+
+        d = self._D
+        self._sb_bw   = dbl(d["BW_per_point"], 1.0, 1e6, 10.0, 1)
+        self._sb_npt  = integer(d["N_point"], 1, 65536)
+        self._lbl_bwfull = QLabel("—")
+        self._lbl_bwfull.setFont(QFont("Courier New", 9))
+        self._sb_tr   = dbl(d["TR"], 1.0, 1e4, 10.0, 1)
+        self._sb_na   = integer(d["NA"], 1, 65536)
+        self._sb_foff = dbl(d["freq_offset"], -1e6, 1e6, 1.0, 1)
+        self._sb_tex  = dbl(d["t_ex"], 0.001, 100.0, 0.1, 3)
+        self._sb_rfadc = dbl(d["rf_adc_delay"], 0.0, 100.0, 0.01, 3)
+        self._sb_scale = dbl(d["scale_rf"], 0.0, 10.0, 0.1, 3)
+
+        form.addRow("BW per point (Hz):", self._sb_bw)
+        form.addRow("N points:",           self._sb_npt)
+        form.addRow("BW full (Hz):",       self._lbl_bwfull)
+        form.addRow("TR (ms):",            self._sb_tr)
+        form.addRow("NA (averages):",      self._sb_na)
+        form.addRow("Freq offset (Hz):",   self._sb_foff)
+        form.addRow("RF pulse dur (ms):",  self._sb_tex)
+        form.addRow("RF-ADC delay (ms):",  self._sb_rfadc)
+        form.addRow("RF scale:",           self._sb_scale)
+        lay.addWidget(param_grp)
+
+        # update BW full on change
+        self._sb_bw.valueChanged.connect(self._update_bwfull)
+        self._sb_npt.valueChanged.connect(self._update_bwfull)
+        self._update_bwfull()
+
+        # ── pulse type ────────────────────────────────────────────────
+        pulse_grp = QGroupBox("Pulse type")
+        pulse_lay = QHBoxLayout(pulse_grp)
+        self._rb_block = QRadioButton("block")
+        self._rb_sinc  = QRadioButton("sinc")
+        self._rb_block.setChecked(True)
+        pulse_btn_grp = QButtonGroup(self)
+        pulse_btn_grp.addButton(self._rb_block)
+        pulse_btn_grp.addButton(self._rb_sinc)
+        pulse_lay.addWidget(self._rb_block)
+        pulse_lay.addWidget(self._rb_sinc)
+        lay.addWidget(pulse_grp)
+
+        # ── output filename ───────────────────────────────────────────
+        from PySide6.QtWidgets import QLineEdit
+        file_grp = QGroupBox("Output filename (no extension)")
+        file_lay = QVBoxLayout(file_grp)
+        self._le_filename = QLineEdit("FID_" + datetime.now().strftime("%d%m%y_%H%M"))
+        file_lay.addWidget(self._le_filename)
+        lay.addWidget(file_grp)
+
+        lay.addStretch()
+
+        scroll = QScrollArea()
+        scroll.setWidget(inner)
+        scroll.setWidgetResizable(True)
+        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        return scroll
+
+    def _build_status_bar(self) -> QFrame:
+        bar = QFrame()
+        bar.setFrameShape(QFrame.StyledPanel)
+        bar.setFixedHeight(22)
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(6, 0, 6, 0)
+        self._status_lbl = QLabel("Ready — set parameters and click Generate")
+        self._status_lbl.setFont(QFont("Arial", 8))
+        lay.addWidget(self._status_lbl)
+        lay.addStretch()
+        return bar
+
+    # ================================================================== #
+    #  Helpers                                                             #
+    # ================================================================== #
+
+    def _update_bwfull(self) -> None:
+        bw_full = self._sb_bw.value() * self._sb_npt.value()
+        self._lbl_bwfull.setText(f"{ceil(bw_full * 1000) / 1000:.1f}")
+
+    def _build_param(self) -> SimpleNamespace:
+        gamma = _GAMMA
+        G_amp_max = _G_AMP_MAX_MT_M * 1e-3 * gamma
+        G_slew_max = _G_SLEW_MAX_T_M_S * gamma
+        rf_raster_time = 1e-6
+        grad_raster_time = 1e-6
+
+        p = SimpleNamespace()
+        p.G_amp_max = G_amp_max
+        p.G_slew_max = G_slew_max
+        p.gamma = gamma
+        p.grad_raster_time = grad_raster_time
+        p.rf_raster_time = rf_raster_time
+        p.rf_dead_time = 0.0
+        p.adc_dead_time = 0.0
+
+        p.BW_per_point = self._sb_bw.value()
+        p.N_point = float(self._sb_npt.value())
+        p.BW_full = p.BW_per_point * p.N_point
+        p.TR = self._sb_tr.value() * 1e-3
+        p.average = float(self._sb_na.value())
+        p.freq_offset = self._sb_foff.value()
+        p.t_ex = self._sb_tex.value() * 1e-3
+        p.rf_ringdown_time = self._sb_rfadc.value() * 1e-3
+        p.scale_rf = self._sb_scale.value()
+        p.is_sinc_pulse = self._rb_sinc.isChecked()
+
+        p.flip_angle = 90.0
+        p.apodization = 0.3
+        p.t_BW_product_ex = 2.18
+        return p
+
+    def _tmp_seq_path(self) -> str:
+        tmp = tempfile.gettempdir()
+        name = self._le_filename.text().strip() or "fid_tmp"
+        return os.path.join(tmp, f"{name}.seq")
+
+    # ================================================================== #
+    #  Actions                                                             #
+    # ================================================================== #
+
+    def _generate(self) -> None:
+        param = self._build_param()
+        seq_path = self._tmp_seq_path()
+        self._last_param = param
+        self._last_seq_path = seq_path
+        self._set_busy(True, "Generating sequence…")
+
+        self._gen_worker = _FidWorker(param, seq_path)
+        self._gen_worker.log_msg.connect(self._status)
+        self._gen_worker.finished.connect(self._on_generated)
+        self._gen_worker.error.connect(self._on_error)
+        self._gen_worker.start()
+
+    def _on_generated(self, seq_path: str) -> None:
+        self._status(f"Generated → {seq_path}  |  Interpreting…")
+        self._interp_worker = LoadInterpWorker(
+            seq_path,
+            hw_config_path=self._hw_config_path,
+        )
+        self._interp_worker.log_msg.connect(self._status)
+        self._interp_worker.finished.connect(self._on_interp_done)
+        self._interp_worker.error.connect(self._on_error)
+        self._interp_worker.start()
+
+    def _on_interp_done(self, seq_data: dict, sync_data: dict, hw) -> None:
+        self._set_busy(False)
+        self._plots.plot_all(seq_data, sync_data)
+        self._btn_save.setEnabled(True)
+        self._btn_load_seq_tab.setEnabled(True)
+        self._status(
+            f"Done — {sync_data['number_of_blocks']} sync blocks  |  "
+            f"Click 'Save' to write .seq or '➡ Load in Sequence Tab'"
+        )
+
+    def _save(self) -> None:
+        if self._last_param is None:
+            return
+        out_dir = self._output_dir
+        if not out_dir:
+            out_dir = QFileDialog.getExistingDirectory(self, "Choose output directory")
+            if not out_dir:
+                return
+            self._output_dir = out_dir
+
+        name = self._le_filename.text().strip() or "fid_output"
+        seq_path = os.path.join(out_dir, f"{name}.seq")
+        json_path = os.path.join(out_dir, f"{name}.json")
+
+        self._set_busy(True, "Saving…")
+        worker = _FidWorker(self._last_param, seq_path)
+        worker.log_msg.connect(self._status)
+        worker.finished.connect(lambda p: self._on_saved(p, json_path))
+        worker.error.connect(self._on_error)
+        worker.start()
+        self._gen_worker = worker
+
+    def _on_saved(self, seq_path: str, json_path: str) -> None:
+        try:
+            with open(json_path, "w") as f:
+                json.dump(vars(self._last_param), f, indent=4, default=str)
+        except Exception as exc:
+            self._on_error(f"JSON save failed: {exc}")
+            return
+        self._last_seq_path = seq_path
+        self._set_busy(False)
+        self._status(f"Saved → {os.path.dirname(seq_path)}")
+        QMessageBox.information(
+            self, "Saved",
+            f".seq and .json written to:\n{os.path.dirname(seq_path)}"
+        )
+
+    def _load_in_seq_tab(self) -> None:
+        if self._last_seq_path and os.path.isfile(self._last_seq_path):
+            self.fid_seq_generated.emit(self._last_seq_path)
+        else:
+            QMessageBox.warning(self, "No sequence",
+                                "Generate and save a sequence first.")
+
+    def _on_error(self, msg: str) -> None:
+        self._set_busy(False)
+        self._status(f"ERROR: {msg}")
+        QMessageBox.critical(self, "Error", msg)
+
+    # ================================================================== #
+    #  UI state helpers                                                    #
+    # ================================================================== #
+
+    def _set_busy(self, busy: bool, tip: str = "") -> None:
+        self._progress.setVisible(busy)
+        self._btn_generate.setEnabled(not busy)
+        if tip:
+            self._status(tip)
+
+    def _status(self, msg: str) -> None:
+        self._status_lbl.setText(msg)

+ 457 - 0
apps/gui/src/tabs/scanner_tab.py

@@ -0,0 +1,457 @@
+"""
+Scanner Control tab — communicates exclusively with the lf_orchestration server.
+All microservice switching (Spectrometer, Reconstructor, etc.) is handled by the
+orchestrator; this tab never connects to those services directly.
+"""
+from __future__ import annotations
+
+import json
+
+from PySide6.QtCore import Qt, QTimer
+from PySide6.QtGui import QFont, QColor
+from PySide6.QtWidgets import (
+    QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
+    QGroupBox, QLabel, QPushButton, QProgressBar,
+    QTextEdit, QScrollArea, QSizePolicy, QTabWidget,
+    QComboBox, QLineEdit, QTableWidget, QTableWidgetItem,
+    QHeaderView, QAbstractItemView,
+)
+
+from src.clients.orchestrator_client import OrchestratorClient, OrchestratorError
+from src.gui.workers import OrchestratorWorker
+
+
+_STATUS_COLORS = {
+    "pending": "#9e9e9e",
+    "running": "#e65100",
+    "done":    "#2e7d32",
+    "failed":  "#c62828",
+}
+
+_POLL_INTERVAL_MS = 1500
+
+
+class ScannerTab(QWidget):
+    """Orchestrator-based scanner control panel."""
+
+    def __init__(
+        self,
+        hw_config_path: str | None = None,
+        orchestrator_url: str = "http://localhost:1717",
+        parent: QWidget | None = None,
+    ) -> None:
+        super().__init__(parent)
+
+        self._hw_config_path = hw_config_path
+        self._client = OrchestratorClient(orchestrator_url)
+        self._job_id: str | None = None
+        self._seq_info: dict | None = None
+
+        # Active workers — kept alive while running
+        self._run_worker: OrchestratorWorker | None = None
+        self._poll_worker: OrchestratorWorker | None = None
+
+        self._poll_timer = QTimer(self)
+        self._poll_timer.setInterval(_POLL_INTERVAL_MS)
+        self._poll_timer.timeout.connect(self._poll_status)
+
+        self._build_layout()
+
+    # ================================================================== #
+    #  Public API                                                          #
+    # ================================================================== #
+
+    def set_hw_config(self, path: str) -> None:
+        self._hw_config_path = path
+        self._append_log(f"HW config: {path}")
+
+    def apply_seq_info(self, info_dict: dict) -> None:
+        """Receive sequence info from SeqInterpTab after export."""
+        self._seq_info = info_dict
+        summary_lines = []
+        if "infostr" in info_dict:
+            summary_lines.append(info_dict["infostr"])
+        if "time" in info_dict:
+            summary_lines.append(info_dict["time"])
+        adc = info_dict.get("iadc", {})
+        if "points" in adc:
+            summary_lines.append(f"ADC windows: {len(adc['points'])}")
+        self._seq_info_label.setText("\n".join(summary_lines) if summary_lines else "—")
+        self._append_log("Sequence info received from Sequence tab.")
+
+    # ================================================================== #
+    #  Layout builders                                                     #
+    # ================================================================== #
+
+    def _build_layout(self) -> None:
+        root = QVBoxLayout(self)
+        root.setContentsMargins(6, 6, 6, 6)
+        root.setSpacing(6)
+
+        root.addWidget(self._build_connection_bar())
+
+        split = QSplitter(Qt.Horizontal)
+        split.addWidget(self._build_left_panel())
+        split.addWidget(self._build_right_panel())
+        split.setSizes([280, 720])
+        root.addWidget(split, stretch=1)
+
+    def _build_connection_bar(self) -> QWidget:
+        bar = QWidget()
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(0, 0, 0, 0)
+
+        lay.addWidget(QLabel("Orchestrator URL:"))
+        self._url_edit = QLineEdit(self._client.base_url)
+        self._url_edit.setMaximumWidth(260)
+        lay.addWidget(self._url_edit)
+
+        self._btn_connect = QPushButton("Connect")
+        self._btn_connect.setFixedWidth(80)
+        self._btn_connect.clicked.connect(self._on_connect)
+        lay.addWidget(self._btn_connect)
+
+        self._status_label = QLabel("● Offline")
+        self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
+        lay.addWidget(self._status_label)
+
+        self._conn_progress = QProgressBar()
+        self._conn_progress.setRange(0, 0)
+        self._conn_progress.setFixedWidth(80)
+        self._conn_progress.setVisible(False)
+        lay.addWidget(self._conn_progress)
+
+        lay.addStretch()
+        return bar
+
+    def _build_left_panel(self) -> QWidget:
+        container = QWidget()
+        container.setMinimumWidth(200)
+        container.setMaximumWidth(320)
+        lay = QVBoxLayout(container)
+        lay.setContentsMargins(4, 4, 4, 4)
+        lay.setSpacing(8)
+
+        # Scenario selector
+        scenario_grp = QGroupBox("Scenario")
+        sg_lay = QVBoxLayout(scenario_grp)
+        self._scenario_combo = QComboBox()
+        self._scenario_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+        sg_lay.addWidget(self._scenario_combo)
+        self._btn_refresh = QPushButton("↻ Refresh")
+        self._btn_refresh.clicked.connect(self._on_refresh_scenarios)
+        sg_lay.addWidget(self._btn_refresh)
+        lay.addWidget(scenario_grp)
+
+        # Sequence summary
+        seq_grp = QGroupBox("Sequence Info")
+        seq_lay = QVBoxLayout(seq_grp)
+        self._seq_info_label = QLabel("No sequence loaded")
+        self._seq_info_label.setWordWrap(True)
+        self._seq_info_label.setStyleSheet("color: palette(mid); font-style: italic;")
+        self._seq_info_label.setFont(QFont("Courier New", 8))
+        seq_lay.addWidget(self._seq_info_label)
+        lay.addWidget(seq_grp)
+
+        # Job info
+        job_grp = QGroupBox("Job")
+        job_lay = QVBoxLayout(job_grp)
+        self._job_label = QLabel("— no job —")
+        self._job_label.setFont(QFont("Courier New", 8))
+        self._job_label.setWordWrap(True)
+        self._job_label.setStyleSheet("color: palette(mid);")
+        job_lay.addWidget(self._job_label)
+        lay.addWidget(job_grp)
+
+        # Control buttons
+        self._btn_load = QPushButton("Load Scenario")
+        self._btn_load.setMinimumHeight(30)
+        self._btn_load.clicked.connect(self._on_load_scenario)
+        lay.addWidget(self._btn_load)
+
+        self._btn_run_all = QPushButton("▶ Run All")
+        self._btn_run_all.setMinimumHeight(30)
+        self._btn_run_all.setEnabled(False)
+        self._btn_run_all.clicked.connect(self._on_run_all)
+        lay.addWidget(self._btn_run_all)
+
+        self._btn_next = QPushButton("⏭ Next Step")
+        self._btn_next.setMinimumHeight(30)
+        self._btn_next.setEnabled(False)
+        self._btn_next.clicked.connect(self._on_next_step)
+        lay.addWidget(self._btn_next)
+
+        self._btn_abort = QPushButton("⏹ Abort")
+        self._btn_abort.setMinimumHeight(30)
+        self._btn_abort.setEnabled(False)
+        self._btn_abort.clicked.connect(self._on_abort)
+        lay.addWidget(self._btn_abort)
+
+        lay.addStretch()
+
+        scroll = QScrollArea()
+        scroll.setWidget(container)
+        scroll.setWidgetResizable(True)
+        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        return scroll
+
+    def _build_right_panel(self) -> QWidget:
+        panel = QWidget()
+        lay = QVBoxLayout(panel)
+        lay.setContentsMargins(4, 4, 4, 4)
+        lay.setSpacing(6)
+
+        # Steps table
+        self._steps_table = QTableWidget(0, 3)
+        self._steps_table.setHorizontalHeaderLabels(["Step", "Status", "Result"])
+        self._steps_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
+        self._steps_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
+        self._steps_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
+        self._steps_table.verticalHeader().setVisible(False)
+        self._steps_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
+        self._steps_table.setSelectionBehavior(QAbstractItemView.SelectRows)
+        self._steps_table.setFont(QFont("Courier New", 9))
+        self._steps_table.currentCellChanged.connect(
+            lambda row, *_: self._on_step_selected(row)
+        )
+        lay.addWidget(self._steps_table, stretch=1)
+
+        # Bottom tabs
+        bottom_tabs = QTabWidget()
+
+        self._step_result_view = QTextEdit()
+        self._step_result_view.setReadOnly(True)
+        self._step_result_view.setFont(QFont("Courier New", 9))
+        bottom_tabs.addTab(self._step_result_view, "Step Result")
+
+        self._seq_info_view = QTextEdit()
+        self._seq_info_view.setReadOnly(True)
+        self._seq_info_view.setFont(QFont("Courier New", 9))
+        bottom_tabs.addTab(self._seq_info_view, "Sequence Info")
+
+        self._log_view = QTextEdit()
+        self._log_view.setReadOnly(True)
+        self._log_view.setFont(QFont("Courier New", 9))
+        bottom_tabs.addTab(self._log_view, "Log")
+
+        lay.addWidget(bottom_tabs, stretch=1)
+        return panel
+
+    # ================================================================== #
+    #  Connection                                                          #
+    # ================================================================== #
+
+    def _on_connect(self) -> None:
+        url = self._url_edit.text().strip()
+        if url:
+            self._client = OrchestratorClient(url)
+
+        self._btn_connect.setEnabled(False)
+        self._conn_progress.setVisible(True)
+
+        worker = OrchestratorWorker(self._client.healthcheck)
+        worker.finished.connect(self._on_healthcheck_done)
+        worker.error.connect(self._on_healthcheck_error)
+        worker.start()
+        self._hc_worker = worker  # keep alive
+
+    def _on_healthcheck_done(self, ok: object) -> None:
+        self._btn_connect.setEnabled(True)
+        self._conn_progress.setVisible(False)
+        if ok:
+            self._status_label.setText("● Online")
+            self._status_label.setStyleSheet("color: #2e7d32; font-weight: bold;")
+            self._append_log(f"Connected to orchestrator: {self._client.base_url}")
+            self._on_refresh_scenarios()
+        else:
+            self._status_label.setText("● Offline")
+            self._status_label.setStyleSheet("color: #9e9e9e; font-weight: bold;")
+            self._append_log("Orchestrator not reachable.")
+
+    def _on_healthcheck_error(self, msg: str) -> None:
+        self._btn_connect.setEnabled(True)
+        self._conn_progress.setVisible(False)
+        self._status_label.setText("● Error")
+        self._status_label.setStyleSheet("color: #c62828; font-weight: bold;")
+        self._append_log(f"Connect error: {msg}")
+
+    # ================================================================== #
+    #  Scenario listing                                                    #
+    # ================================================================== #
+
+    def _on_refresh_scenarios(self) -> None:
+        worker = OrchestratorWorker(self._client.list_scenarios)
+        worker.finished.connect(self._on_scenarios_loaded)
+        worker.error.connect(lambda msg: self._append_log(f"List error: {msg}"))
+        worker.start()
+        self._list_worker = worker
+
+    def _on_scenarios_loaded(self, scenarios: object) -> None:
+        self._scenario_combo.clear()
+        for s in (scenarios or []):
+            self._scenario_combo.addItem(s)
+        self._append_log(f"Scenarios: {list(scenarios or [])}")
+
+    # ================================================================== #
+    #  Job control                                                         #
+    # ================================================================== #
+
+    def _on_load_scenario(self) -> None:
+        scenario_id = self._scenario_combo.currentText()
+        if not scenario_id:
+            self._append_log("No scenario selected.")
+            return
+
+        param_overrides = None
+        if self._seq_info:
+            param_overrides = {"start_measurement": {"info": self._seq_info}}
+
+        self._append_log(f"Loading scenario '{scenario_id}'…")
+        worker = OrchestratorWorker(
+            self._client.load_scenario, scenario_id, param_overrides
+        )
+        worker.finished.connect(self._on_scenario_loaded)
+        worker.error.connect(lambda msg: self._append_log(f"Load error: {msg}"))
+        worker.start()
+        self._load_worker = worker
+
+    def _on_scenario_loaded(self, job_id: object) -> None:
+        self._job_id = str(job_id)
+        self._job_label.setText(self._job_id[:24] + "…" if len(self._job_id) > 24 else self._job_id)
+        self._append_log(f"Job created: {self._job_id}")
+        self._btn_run_all.setEnabled(True)
+        self._btn_next.setEnabled(True)
+        self._btn_abort.setEnabled(False)
+        self._steps_table.setRowCount(0)
+        # Fetch initial step list
+        self._fetch_status_once()
+
+    def _on_run_all(self) -> None:
+        if not self._job_id:
+            return
+        self._append_log("Running all steps…")
+        self._btn_run_all.setEnabled(False)
+        self._btn_next.setEnabled(False)
+        self._btn_abort.setEnabled(True)
+
+        self._run_worker = OrchestratorWorker(self._client.run_all, self._job_id)
+        self._run_worker.finished.connect(self._on_run_all_done)
+        self._run_worker.error.connect(self._on_worker_error)
+        self._run_worker.start()
+
+        self._poll_timer.start()
+
+    def _on_run_all_done(self, result: object) -> None:
+        self._poll_timer.stop()
+        self._btn_abort.setEnabled(False)
+        self._append_log("Run all complete.")
+        if isinstance(result, dict) and "steps" in result:
+            self._update_steps_table(result["steps"])
+
+    def _on_next_step(self) -> None:
+        if not self._job_id:
+            return
+        worker = OrchestratorWorker(self._client.next_step, self._job_id)
+        worker.finished.connect(self._on_next_done)
+        worker.error.connect(self._on_worker_error)
+        worker.start()
+        self._next_worker = worker
+
+    def _on_next_done(self, result: object) -> None:
+        self._append_log("Step executed.")
+        self._fetch_status_once()
+
+    def _on_abort(self) -> None:
+        self._poll_timer.stop()
+        if self._run_worker and self._run_worker.isRunning():
+            self._run_worker.terminate()
+        self._btn_run_all.setEnabled(True)
+        self._btn_next.setEnabled(True)
+        self._btn_abort.setEnabled(False)
+        self._append_log("Aborted.")
+
+    def _on_worker_error(self, msg: str) -> None:
+        self._poll_timer.stop()
+        self._btn_run_all.setEnabled(True)
+        self._btn_next.setEnabled(True)
+        self._btn_abort.setEnabled(False)
+        self._append_log(f"Error: {msg}")
+
+    # ================================================================== #
+    #  Polling                                                             #
+    # ================================================================== #
+
+    def _fetch_status_once(self) -> None:
+        if not self._job_id:
+            return
+        worker = OrchestratorWorker(self._client.get_status, self._job_id)
+        worker.finished.connect(self._on_status_received)
+        worker.error.connect(lambda msg: self._append_log(f"Poll error: {msg}"))
+        worker.start()
+        self._status_worker = worker
+
+    def _poll_status(self) -> None:
+        if self._poll_worker and self._poll_worker.isRunning():
+            return  # previous poll still in flight
+        self._poll_worker = OrchestratorWorker(self._client.get_status, self._job_id)
+        self._poll_worker.finished.connect(self._on_status_received)
+        self._poll_worker.error.connect(lambda msg: None)  # silently ignore poll errors
+        self._poll_worker.start()
+
+    def _on_status_received(self, status: object) -> None:
+        if not isinstance(status, dict):
+            return
+        steps = status.get("steps", [])
+        self._update_steps_table(steps)
+
+    # ================================================================== #
+    #  Steps table                                                         #
+    # ================================================================== #
+
+    def _update_steps_table(self, steps: list) -> None:
+        current_row = self._steps_table.currentRow()
+        self._steps_table.setRowCount(len(steps))
+        for row, step in enumerate(steps):
+            name = step.get("name", "")
+            status = step.get("status", "pending").lower()
+            result = step.get("result", "")
+            result_str = json.dumps(result, default=str)[:80] if result else ""
+
+            name_item = QTableWidgetItem(name)
+            status_item = QTableWidgetItem(status)
+            result_item = QTableWidgetItem(result_str)
+
+            color = QColor(_STATUS_COLORS.get(status, "#9e9e9e"))
+            for item in (name_item, status_item, result_item):
+                item.setForeground(color)
+
+            self._steps_table.setItem(row, 0, name_item)
+            self._steps_table.setItem(row, 1, status_item)
+            self._steps_table.setItem(row, 2, result_item)
+
+        if current_row >= 0:
+            self._steps_table.setCurrentCell(current_row, 0)
+
+        # Store full step data for detail view
+        self._steps_data = steps
+
+    def _on_step_selected(self, row: int) -> None:
+        if not hasattr(self, "_steps_data") or row < 0 or row >= len(self._steps_data):
+            return
+        step = self._steps_data[row]
+        result = step.get("result", None)
+        self._step_result_view.setPlainText(
+            json.dumps(result, indent=2, default=str) if result is not None else ""
+        )
+
+    # ================================================================== #
+    #  Log                                                                 #
+    # ================================================================== #
+
+    def _append_log(self, msg: str) -> None:
+        self._log_view.append(msg)
+        if self._seq_info is not None:
+            # Also refresh seq info view with latest raw dict
+            self._seq_info_view.setPlainText(
+                json.dumps(self._seq_info, indent=2, default=str)
+            )

+ 816 - 0
apps/gui/src/tabs/scanning_tab.py

@@ -0,0 +1,816 @@
+"""
+ScanningTab — clinical MRI scanner UI simulation with real scan initiation.
+
+Panels:
+  - Left:   scrollable protocol queue  (ProtocolListWidget)
+  - Centre: 3 MRI image viewers       (MriViewerWidget × 3)
+  - Bottom: QTabWidget with parameter tabs; "Геометрия" tab holds rotation controls
+
+Rotation matrix (3×3, ZYX Euler) is merged into the seq_info dict and sent
+to the orchestrator's start_measurement step as "rotation_matrix".
+"""
+from __future__ import annotations
+
+import math
+import json
+import numpy as np
+
+from PySide6.QtCore import Qt, QThread, QTimer, Signal
+from PySide6.QtGui import (
+    QColor, QFont, QImage, QLinearGradient, QPainter, QPen, QPolygonF,
+)
+from PySide6.QtCore import QPointF
+from PySide6.QtWidgets import (
+    QButtonGroup, QDoubleSpinBox, QFormLayout, QGridLayout,
+    QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
+    QPushButton, QSplitter, QTabWidget, QVBoxLayout, QWidget,
+)
+
+try:
+    import httpx as _httpx
+    _HAS_HTTPX = True
+except ImportError:
+    _HAS_HTTPX = False
+
+# ── colour palette ─────────────────────────────────────────────────────────────
+_BG_DARK      = "#1a1a2e"
+_PANEL_BG     = "#2a2a2a"
+_IMAGE_BG     = "#000000"
+_ACCENT       = "#f0c040"
+_ACCENT_DIM   = "#555533"
+_ORANGE_SEL   = "#e65100"
+_BTN_BG       = "#252535"
+
+# ── animation ─────────────────────────────────────────────────────────────────
+_SCAN_LINE_INTERVAL_MS = 40    # 25 fps
+_SCAN_SWEEP_DURATION   = 120   # ticks per vertical sweep
+
+# ── mouse interaction ─────────────────────────────────────────────────────────
+_ROT_SENSITIVITY = 0.4         # degrees per pixel for Ctrl+drag rotation
+
+# ── default orientations (Rx, Ry, Rz in degrees) ──────────────────────────────
+_PRESETS = {
+    "Axial":    (0.0,  0.0,  0.0),
+    "Coronal":  (90.0, 0.0,  0.0),
+    "Sagittal": (0.0,  90.0, 0.0),
+}
+
+_PROTOCOLS = [
+    "FID", "SE", "TSE"
+]
+
+# Physical axes for each viewer plane: (horizontal_axis, vertical_axis)
+# X=0, Y=1, Z=2 in physical space; Y is flipped to screen coords in paintEvent
+_VIEWER_AXES: dict[str, tuple[int, int]] = {
+    "Axial": (0, 1),
+    "Cor":   (0, 2),
+    "Sag":   (1, 2),
+}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+def _euler_to_matrix(rx_deg: float, ry_deg: float, rz_deg: float) -> list:
+    """ZYX Euler angles (degrees) → 3×3 rotation matrix as list-of-lists."""
+    rx = math.radians(rx_deg)
+    ry = math.radians(ry_deg)
+    rz = math.radians(rz_deg)
+    cx, sx = math.cos(rx), math.sin(rx)
+    cy, sy = math.cos(ry), math.sin(ry)
+    cz, sz = math.cos(rz), math.sin(rz)
+    Rx = np.array([[1,  0,   0 ],
+                   [0,  cx, -sx],
+                   [0,  sx,  cx]])
+    Ry = np.array([[ cy, 0, sy],
+                   [ 0,  1,  0],
+                   [-sy, 0, cy]])
+    Rz = np.array([[cz, -sz, 0],
+                   [sz,  cz, 0],
+                   [0,   0,  1]])
+    R = Rz @ Ry @ Rx
+    return [[round(float(v), 6) for v in row] for row in R]
+
+
+def _project_slice_quad(R: list, viewer_label: str, size: float = 0.42) -> list:
+    """
+    Project the scan-plane square onto a viewer's 2D projection plane.
+
+    The scan square lies in the (freq-encode, phase-encode) plane.
+    freq direction = R column 0, phase direction = R column 1.
+
+    Viewer planes:
+      "Axial" → XY  (physical axes 0, 1)
+      "Cor"   → XZ  (physical axes 0, 2)
+      "Sag"   → YZ  (physical axes 1, 2)
+
+    Returns list of 4 (u, v) tuples in [-1, 1] space, Y-up convention.
+    """
+    ax0, ax1 = _VIEWER_AXES.get(viewer_label, (0, 1))
+
+    # freq and phase unit vectors in physical space
+    freq  = [R[i][0] for i in range(3)]
+    phase = [R[i][1] for i in range(3)]
+
+    corners = []
+    for sf, sp in ((size, size), (-size, size), (-size, -size), (size, -size)):
+        pt3 = [freq[i] * sf + phase[i] * sp for i in range(3)]
+        corners.append((pt3[ax0], pt3[ax1]))
+    return corners
+
+
+def _generate_noise_image(w: int = 256, h: int = 256) -> QImage:
+    rng  = np.random.default_rng(42)
+    data = rng.integers(0, 70, (h, w), dtype=np.uint8)
+    cy, cx = h / 2, w / 2
+    Y, X   = np.ogrid[:h, :w]
+    dist   = np.sqrt(((X - cx) / cx) ** 2 + ((Y - cy) / cy) ** 2)
+    mask   = np.clip(1.0 - dist * 0.85, 0.05, 1.0)
+    data   = (data * mask).astype(np.uint8)
+    alpha  = np.full((h, w), 200, dtype=np.uint8)
+    rgba   = np.stack([data, data, data, alpha], axis=-1)
+    img    = QImage(rgba.tobytes(), w, h, 4 * w, QImage.Format_RGBA8888)
+    return img.copy()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+class MriViewerWidget(QWidget):
+    """
+    Dark MRI image viewer — pure QPainter.
+
+    Ctrl + left-drag moves the slice square within the viewer plane.
+    The widget emits slice_offset_changed(ax0, ax1, d0, d1) so ScanningTab
+    can accumulate a shared 3-D offset vector and push it back to all viewers.
+    """
+
+    # slice_offset_changed(physical_axis_u, physical_axis_v, delta_u, delta_v)
+    slice_offset_changed = Signal(int, int, float, float)
+    # rotation_delta(drx_deg, dry_deg) — Ctrl+drag gamedev-style rotation
+    rotation_delta = Signal(float, float)
+
+    _IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
+
+    def __init__(self, label: str = "Axial", parent: QWidget | None = None) -> None:
+        super().__init__(parent)
+        self._label        = label
+        self._scan_y       = 0
+        self._active_scan  = False
+        self._rot_matrix   = [row[:] for row in self._IDENTITY]
+        self._slice_offset = [0.0, 0.0, 0.0]   # 3-D offset in logical [-1, 1] space
+        self._drag_pos     = None               # QPointF while dragging
+        self._drag_mode    = None               # 'move' | 'rotate'
+        self._noise        = _generate_noise_image()
+        self.setMinimumSize(120, 120)
+        self.setMouseTracking(True)             # needed for cursor updates without button
+
+    # ── public API ─────────────────────────────────────────────────────────
+    def set_scanning(self, active: bool) -> None:
+        self._active_scan = active
+        self.update()
+
+    def set_rotation_matrix(self, R: list) -> None:
+        self._rot_matrix = R
+        self.update()
+
+    def set_slice_offset(self, offset: list) -> None:
+        self._slice_offset = list(offset)
+        self.update()
+
+    def advance_scanline(self, phase: int) -> None:
+        if self.height() == 0:
+            return
+        self._scan_y = int(phase / _SCAN_SWEEP_DURATION * self.height())
+        self.update()
+
+    # ── mouse interaction ──────────────────────────────────────────────────
+    #   LMB drag          → move slice (translate)
+    #   Ctrl + LMB drag   → rotate slice (gamedev orbit: dx=Ry yaw, dy=Rx pitch)
+
+    def _pixel_scale(self) -> float:
+        return min(self.width(), self.height()) * 0.46
+
+    def _hover_cursor(self, modifiers) -> Qt.CursorShape:
+        return Qt.OpenHandCursor if (modifiers & Qt.ControlModifier) else Qt.SizeAllCursor
+
+    def mousePressEvent(self, event) -> None:  # noqa: N802
+        if event.button() == Qt.LeftButton:
+            self._drag_pos  = event.position()
+            self._drag_mode = "rotate" if (event.modifiers() & Qt.ControlModifier) else "move"
+            self.setCursor(Qt.ClosedHandCursor if self._drag_mode == "rotate" else Qt.SizeAllCursor)
+            event.accept()
+        else:
+            super().mousePressEvent(event)
+
+    def mouseMoveEvent(self, event) -> None:  # noqa: N802
+        if not (event.buttons() & Qt.LeftButton):
+            self.setCursor(self._hover_cursor(event.modifiers()))
+
+        if self._drag_pos is not None and (event.buttons() & Qt.LeftButton):
+            pos = event.position()
+            dx  = pos.x() - self._drag_pos.x()
+            dy  = pos.y() - self._drag_pos.y()
+
+            if self._drag_mode == "move":
+                scale = self._pixel_scale()
+                if scale > 0:
+                    ax0, ax1 = _VIEWER_AXES[self._label]
+                    self.slice_offset_changed.emit(ax0, ax1, dx / scale, -dy / scale)
+            else:  # rotate — gamedev FPS orbit
+                # horizontal drag → yaw  (Ry)
+                # vertical drag   → pitch (Rx); screen-down = positive pitch
+                self.rotation_delta.emit(
+                    dy * _ROT_SENSITIVITY,   # drx
+                    dx * _ROT_SENSITIVITY,   # dry
+                )
+
+            self._drag_pos = pos
+            event.accept()
+        else:
+            super().mouseMoveEvent(event)
+
+    def mouseReleaseEvent(self, event) -> None:  # noqa: N802
+        if event.button() == Qt.LeftButton and self._drag_pos is not None:
+            self._drag_pos  = None
+            self._drag_mode = None
+            self.setCursor(self._hover_cursor(event.modifiers()))
+            event.accept()
+        else:
+            super().mouseReleaseEvent(event)
+
+    # ── painting ───────────────────────────────────────────────────────────
+    def paintEvent(self, event) -> None:  # noqa: N802
+        p  = QPainter(self)
+        rc = self.rect()
+        w, h = rc.width(), rc.height()
+
+        # 1 — background
+        p.fillRect(rc, QColor(_IMAGE_BG))
+
+        # 2 — noise texture
+        p.setOpacity(0.9)
+        p.drawImage(rc, self._noise)
+        p.setOpacity(1.0)
+
+        # 3 — crosshair
+        cx, cy = w // 2, h // 2
+        p.setPen(QPen(QColor("#444444"), 1))
+        p.drawLine(cx - int(w * 0.30), cy, cx + int(w * 0.30), cy)
+        p.drawLine(cx, cy - int(h * 0.30), cx, cy + int(h * 0.30))
+
+        # 4 — scale ticks (3 per edge at 25/50/75 %)
+        p.setPen(QPen(QColor("#888888"), 1))
+        tick = 8
+        for frac in (0.25, 0.50, 0.75):
+            tx, ty = int(w * frac), int(h * frac)
+            p.drawLine(tx, 0,        tx, tick)
+            p.drawLine(tx, h - tick, tx, h)
+            p.drawLine(0,        ty, tick, ty)
+            p.drawLine(w - tick, ty, w,    ty)
+
+        # 5 — dim outer border
+        p.setPen(QPen(QColor(_ACCENT_DIM), 1))
+        p.drawRect(rc.adjusted(2, 2, -2, -2))
+
+        # 6 — projected slice square (with 3-D offset applied)
+        scale  = self._pixel_scale()
+        ax0, ax1 = _VIEWER_AXES[self._label]
+        off_u  = self._slice_offset[ax0]
+        off_v  = self._slice_offset[ax1]
+        origin_x = cx + off_u * scale
+        origin_y = cy - off_v * scale    # Y-up → Y-down
+
+        corners_uv = _project_slice_quad(self._rot_matrix, self._label)
+        pts = QPolygonF([
+            QPointF(origin_x + u * scale, origin_y - v * scale)
+            for u, v in corners_uv
+        ])
+        # semi-transparent fill
+        p.setPen(Qt.NoPen)
+        p.setBrush(QColor(240, 192, 64, 45))
+        p.drawPolygon(pts)
+        # solid border
+        pen = QPen(QColor(_ACCENT), 2)
+        pen.setJoinStyle(Qt.MiterJoin)
+        p.setPen(pen)
+        p.setBrush(Qt.NoBrush)
+        p.drawPolygon(pts)
+
+        # 7 — scan sweep stripe
+        if self._active_scan:
+            sy   = self._scan_y
+            grad = QLinearGradient(0, sy, w, sy)
+            grad.setColorAt(0.0, QColor(0, 0, 0, 0))
+            grad.setColorAt(0.5, QColor(255, 255, 255, 96))
+            grad.setColorAt(1.0, QColor(0, 0, 0, 0))
+            p.fillRect(0, max(0, sy - 2), w, 5, grad)
+
+        # 8 — orientation label
+        font = QFont("Arial", 11, QFont.Bold)
+        p.setFont(font)
+        p.setPen(Qt.white)
+        p.drawText(rc.adjusted(8, 4, 0, 0), Qt.AlignTop | Qt.AlignLeft, self._label)
+
+        p.end()
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+class ProtocolListWidget(QWidget):
+    protocol_selected = Signal(str)
+
+    def __init__(self, parent: QWidget | None = None) -> None:
+        super().__init__(parent)
+        self.setStyleSheet(f"background: {_PANEL_BG};")
+
+        lay = QVBoxLayout(self)
+        lay.setContentsMargins(4, 8, 4, 4)
+        lay.setSpacing(4)
+
+        header = QLabel("Протоколы")
+        header.setStyleSheet(
+            "color: #aaaaaa; font-weight: bold; font-size: 11px; background: transparent;"
+        )
+        lay.addWidget(header)
+
+        self._list = QListWidget()
+        self._list.setStyleSheet(f"""
+            QListWidget {{
+                background: {_PANEL_BG};
+                color: #ffffff;
+                border: 1px solid #3a3a3a;
+                font-size: 12px;
+                outline: 0;
+            }}
+            QListWidget::item {{
+                padding: 6px 8px;
+                border-bottom: 1px solid #333333;
+            }}
+            QListWidget::item:selected {{
+                background: {_ORANGE_SEL};
+                color: #ffffff;
+            }}
+            QListWidget::item:hover:!selected {{
+                background: #3a3a3a;
+            }}
+        """)
+        for name in _PROTOCOLS:
+            self._list.addItem(name)
+        self._list.setCurrentRow(0)
+        self._list.currentTextChanged.connect(self.protocol_selected)
+        lay.addWidget(self._list, stretch=1)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+class _ScanWorker(QThread):
+    """Fire-and-forget: load scenario and run_all via orchestrator REST."""
+
+    finished = Signal(str)   # job_id or success message
+    error    = Signal(str)
+
+    def __init__(self, url: str, info: dict, parent=None) -> None:
+        super().__init__(parent)
+        self._url  = url.rstrip("/")
+        self._info = info
+
+    def run(self) -> None:
+        try:
+            if not _HAS_HTTPX:
+                import urllib.request, urllib.error
+                self._run_urllib()
+            else:
+                self._run_httpx()
+        except Exception as exc:
+            self.error.emit(str(exc))
+
+    def _run_httpx(self) -> None:
+        payload = {"param_overrides": {"start_measurement": {"info": self._info}}}
+        with _httpx.Client(timeout=15) as client:
+            r = client.post(
+                f"{self._url}/scenario/load/full_pipeline",
+                json=payload,
+            )
+            r.raise_for_status()
+            job_id = r.json().get("job_id", "?")
+            r2 = client.post(f"{self._url}/scenario/{job_id}/run_all")
+            r2.raise_for_status()
+            self.finished.emit(f"job_id={job_id}")
+
+    def _run_urllib(self) -> None:
+        import urllib.request
+        payload = {"param_overrides": {"start_measurement": {"info": self._info}}}
+        data    = json.dumps(payload).encode()
+        headers = {"Content-Type": "application/json"}
+
+        req  = urllib.request.Request(
+            f"{self._url}/scenario/load/full_pipeline",
+            data=data, headers=headers, method="POST",
+        )
+        with urllib.request.urlopen(req, timeout=15) as resp:
+            body   = json.loads(resp.read())
+            job_id = body.get("job_id", "?")
+
+        req2 = urllib.request.Request(
+            f"{self._url}/scenario/{job_id}/run_all",
+            data=b"{}", headers=headers, method="POST",
+        )
+        with urllib.request.urlopen(req2, timeout=15):
+            pass
+        self.finished.emit(f"job_id={job_id}")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+class ScanningTab(QWidget):
+    """
+    Operator-facing scanning tab.
+
+    Bottom QTabWidget tabs match Siemens syngo layout:
+      Основные | Контраст | Разрешение | Геометрия | Система
+
+    "Геометрия" holds orientation presets + Rx/Ry/Rz spinboxes + live 3×3 matrix.
+    """
+
+    def __init__(self, parent: QWidget | None = None) -> None:
+        super().__init__(parent)
+        self.setStyleSheet(f"background: {_BG_DARK};")
+
+        self._viewers:          list[MriViewerWidget] = []
+        self._scan_tick:        int                   = 0
+        self._seq_info:         dict | None           = None
+        self._orchestrator_url: str                   = "http://localhost:1717"
+        self._scan_worker:      _ScanWorker | None    = None
+        self._active_protocol:  str                   = _PROTOCOLS[0]
+        self._slice_offset:     list[float]           = [0.0, 0.0, 0.0]
+
+        root = QVBoxLayout(self)
+        root.setContentsMargins(0, 0, 0, 0)
+        root.setSpacing(0)
+
+        vsplit = QSplitter(Qt.Vertical)
+        vsplit.setStyleSheet("QSplitter::handle { background: #333344; height: 3px; }")
+        vsplit.addWidget(self._build_upper_area())
+        vsplit.addWidget(self._build_bottom_panel())
+        vsplit.setSizes([700, 140])
+        vsplit.setChildrenCollapsible(False)
+        root.addWidget(vsplit, stretch=1)
+
+        self._setup_animation()
+        self._update_matrix_display()
+        self._update_slice_display()
+        self._update_scan_ready_state()
+
+    # ── public API ─────────────────────────────────────────────────────────
+
+    def apply_seq_info(self, info_dict: dict) -> None:
+        """Receive exported sequence info from SeqInterpTab."""
+        self._seq_info = dict(info_dict)
+        self._update_scan_ready_state()
+
+    def set_orchestrator_url(self, url: str) -> None:
+        self._orchestrator_url = url
+
+    # ── layout builders ────────────────────────────────────────────────────
+
+    def _build_upper_area(self) -> QWidget:
+        container = QWidget()
+        container.setStyleSheet(f"background: {_BG_DARK};")
+        lay = QVBoxLayout(container)
+        lay.setContentsMargins(0, 0, 0, 0)
+        lay.setSpacing(0)
+
+        hsplit = QSplitter(Qt.Horizontal)
+        hsplit.setStyleSheet("QSplitter::handle { background: #333344; width: 3px; }")
+        hsplit.addWidget(self._build_protocol_panel())
+        hsplit.addWidget(self._build_image_grid())
+        hsplit.setSizes([220, 980])
+        hsplit.setChildrenCollapsible(False)
+        lay.addWidget(hsplit)
+        return container
+
+    def _build_protocol_panel(self) -> QWidget:
+        self._protocol_list = ProtocolListWidget()
+        self._protocol_list.protocol_selected.connect(self._on_protocol_selected)
+        return self._protocol_list
+
+    def _build_image_grid(self) -> QWidget:
+        container = QWidget()
+        container.setStyleSheet(f"background: {_BG_DARK};")
+        grid = QGridLayout(container)
+        grid.setContentsMargins(6, 6, 6, 6)
+        grid.setSpacing(6)
+        for col, lbl in enumerate(("Axial", "Cor", "Sag")):
+            viewer = MriViewerWidget(label=lbl)
+            viewer.slice_offset_changed.connect(self._on_slice_offset_changed)
+            viewer.rotation_delta.connect(self._on_rotation_delta)
+            grid.addWidget(viewer, 0, col)
+            grid.setColumnStretch(col, 1)
+            self._viewers.append(viewer)
+        grid.setRowStretch(0, 1)
+        return container
+
+    def _build_bottom_panel(self) -> QWidget:
+        panel = QWidget()
+        panel.setStyleSheet("background: #16162a; border-top: 1px solid #333355;")
+
+        outer = QVBoxLayout(panel)
+        outer.setContentsMargins(0, 0, 0, 0)
+        outer.setSpacing(0)
+
+        # ── parameter tabs ─────────────────────────────────────────────────
+        self._param_tabs = QTabWidget()
+        self._param_tabs.setStyleSheet("""
+            QTabWidget::pane  { border: none; background: #16162a; }
+            QTabBar::tab {
+                background: #1e1e38; color: #aaaacc;
+                padding: 5px 14px; border: 1px solid #333355;
+                border-bottom: none; margin-right: 2px; font-size: 11px;
+            }
+            QTabBar::tab:selected { background: #252545; color: #ffffff; }
+            QTabBar::tab:hover:!selected { background: #222240; }
+        """)
+
+        placeholder_tabs = ["Основные", "Контраст", "Разрешение", "Система"]
+        for name in placeholder_tabs:
+            w = QLabel(f"[ {name} — TODO ]")
+            w.setAlignment(Qt.AlignCenter)
+            w.setStyleSheet("color: #555577; background: #16162a;")
+            self._param_tabs.addTab(w, name)
+
+        geo_tab = self._build_geometry_tab()
+        self._param_tabs.addTab(geo_tab, "Геометрия")
+        self._param_tabs.setCurrentWidget(geo_tab)
+
+        outer.addWidget(self._param_tabs, stretch=1)
+
+        # ── bottom action bar ──────────────────────────────────────────────
+        action_bar = QWidget()
+        action_bar.setStyleSheet("background: #16162a; border-top: 1px solid #2a2a4a;")
+        action_lay = QHBoxLayout(action_bar)
+        action_lay.setContentsMargins(12, 6, 12, 6)
+        action_lay.setSpacing(12)
+
+        self._status_label = QLabel("Нет данных")
+        self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
+        action_lay.addWidget(self._status_label)
+        action_lay.addStretch()
+
+        self._btn_scan = QPushButton("▶  Сканировать")
+        self._btn_scan.setCheckable(True)
+        self._btn_scan.setMinimumWidth(140)
+        self._btn_scan.setStyleSheet(
+            "QPushButton {"
+            "  background: #1a3a1a; color: #88ee88;"
+            "  border: 1px solid #336633; border-radius: 4px;"
+            "  font-size: 12px; font-weight: bold; padding: 5px 16px;"
+            "}"
+            "QPushButton:checked {"
+            "  background: #3a1a1a; color: #ee8888; border-color: #663333;"
+            "}"
+            "QPushButton:hover:!checked { background: #1e4a1e; }"
+        )
+        self._btn_scan.toggled.connect(self._on_scan_toggled)
+        action_lay.addWidget(self._btn_scan)
+
+        outer.addWidget(action_bar)
+        return panel
+
+    def _build_geometry_tab(self) -> QWidget:
+        w = QWidget()
+        w.setStyleSheet("background: #16162a;")
+        lay = QHBoxLayout(w)
+        lay.setContentsMargins(12, 8, 12, 8)
+        lay.setSpacing(16)
+
+        # ── orientation presets ────────────────────────────────────────────
+        preset_group = QGroupBox("Ориентация")
+        preset_group.setStyleSheet(self._group_style())
+        preset_lay = QVBoxLayout(preset_group)
+        preset_lay.setSpacing(4)
+
+        self._btn_group = QButtonGroup(self)
+        self._btn_group.setExclusive(True)
+        self._preset_buttons: dict[str, QPushButton] = {}
+        for name in ("Axial", "Coronal", "Sagittal"):
+            btn = QPushButton(name)
+            btn.setCheckable(True)
+            btn.setStyleSheet(self._preset_btn_style())
+            preset_lay.addWidget(btn)
+            self._btn_group.addButton(btn)
+            self._preset_buttons[name] = btn
+            btn.clicked.connect(lambda checked, n=name: self._on_preset(n))
+        self._preset_buttons["Axial"].setChecked(True)
+        lay.addWidget(preset_group)
+
+        # ── rotation angles ────────────────────────────────────────────────
+        rot_group = QGroupBox("Поворот")
+        rot_group.setStyleSheet(self._group_style())
+        form = QFormLayout(rot_group)
+        form.setSpacing(6)
+        form.setContentsMargins(8, 4, 8, 4)
+
+        spin_style = (
+            "QDoubleSpinBox {"
+            "  background: #252540; color: #ddddff;"
+            "  border: 1px solid #445; border-radius: 3px; padding: 2px 4px;"
+            "}"
+            "QDoubleSpinBox:focus { border-color: #f0c040; }"
+        )
+        lbl_style = "color: #aaaacc; font-size: 11px;"
+
+        self._spin_rx = QDoubleSpinBox()
+        self._spin_ry = QDoubleSpinBox()
+        self._spin_rz = QDoubleSpinBox()
+        for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
+            spin.setRange(-180.0, 180.0)
+            spin.setSingleStep(1.0)
+            spin.setDecimals(1)
+            spin.setSuffix(" °")
+            spin.setStyleSheet(spin_style)
+            spin.setMinimumWidth(90)
+            spin.valueChanged.connect(self._on_rotation_changed)
+
+        for lbl_text, spin in (("Rx", self._spin_rx), ("Ry", self._spin_ry), ("Rz", self._spin_rz)):
+            lbl = QLabel(lbl_text)
+            lbl.setStyleSheet(lbl_style)
+            form.addRow(lbl, spin)
+        lay.addWidget(rot_group)
+
+        # ── rotation matrix display ────────────────────────────────────────
+        matrix_group = QGroupBox("Матрица поворота")
+        matrix_group.setStyleSheet(self._group_style())
+        matrix_lay = QVBoxLayout(matrix_group)
+        matrix_lay.setContentsMargins(8, 4, 8, 4)
+
+        self._matrix_label = QLabel()
+        self._matrix_label.setFont(QFont("Courier New", 10))
+        self._matrix_label.setStyleSheet("color: #99ccff; background: transparent;")
+        self._matrix_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
+        matrix_lay.addWidget(self._matrix_label)
+        lay.addWidget(matrix_group)
+
+        lay.addStretch()
+        return w
+
+    @staticmethod
+    def _group_style() -> str:
+        return (
+            "QGroupBox {"
+            "  color: #8888aa; font-size: 10px; font-weight: bold;"
+            "  border: 1px solid #333355; border-radius: 4px; margin-top: 6px;"
+            "}"
+            "QGroupBox::title { subcontrol-origin: margin; left: 6px; top: -1px; }"
+        )
+
+    @staticmethod
+    def _preset_btn_style() -> str:
+        return (
+            "QPushButton {"
+            f"  background: {_BTN_BG}; color: #ccccee;"
+            "  border: 1px solid #444466; border-radius: 3px;"
+            "  font-size: 11px; padding: 4px 10px;"
+            "}"
+            "QPushButton:checked { background: #e65100; color: #ffffff; border-color: #ff7733; }"
+            "QPushButton:hover:!checked { background: #303050; }"
+        )
+
+    # ── animation ──────────────────────────────────────────────────────────
+
+    def _setup_animation(self) -> None:
+        self._scan_timer = QTimer(self)
+        self._scan_timer.setInterval(_SCAN_LINE_INTERVAL_MS)
+        self._scan_timer.timeout.connect(self._on_tick)
+
+    def _on_tick(self) -> None:
+        self._scan_tick += 1
+        offset = _SCAN_SWEEP_DURATION // max(len(self._viewers), 1)
+        for i, viewer in enumerate(self._viewers):
+            phase = (self._scan_tick + i * offset) % _SCAN_SWEEP_DURATION
+            viewer.advance_scanline(phase)
+
+    # ── rotation logic ─────────────────────────────────────────────────────
+
+    def _on_preset(self, name: str) -> None:
+        rx, ry, rz = _PRESETS[name]
+        for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
+            spin.blockSignals(True)
+        self._spin_rx.setValue(rx)
+        self._spin_ry.setValue(ry)
+        self._spin_rz.setValue(rz)
+        for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
+            spin.blockSignals(False)
+        self._on_rotation_changed()
+
+    def _on_rotation_changed(self) -> None:
+        self._update_matrix_display()
+        self._update_slice_display()
+        self._sync_preset_buttons()
+
+    def _compute_rotation_matrix(self) -> list:
+        return _euler_to_matrix(
+            self._spin_rx.value(),
+            self._spin_ry.value(),
+            self._spin_rz.value(),
+        )
+
+    def _update_matrix_display(self) -> None:
+        R = self._compute_rotation_matrix()
+        lines = []
+        for row in R:
+            lines.append("  ".join(f"{v:+.3f}" for v in row))
+        self._matrix_label.setText("\n".join(lines))
+
+    def _update_slice_display(self) -> None:
+        """Push current rotation matrix and offset to all viewers."""
+        R = self._compute_rotation_matrix()
+        for v in self._viewers:
+            v.set_rotation_matrix(R)
+            v.set_slice_offset(self._slice_offset)
+
+    def _on_slice_offset_changed(self, ax0: int, ax1: int, d0: float, d1: float) -> None:
+        """Accumulate drag delta from any viewer into the shared 3-D offset."""
+        self._slice_offset[ax0] = max(-0.95, min(0.95, self._slice_offset[ax0] + d0))
+        self._slice_offset[ax1] = max(-0.95, min(0.95, self._slice_offset[ax1] + d1))
+        R = self._compute_rotation_matrix()
+        for v in self._viewers:
+            v.set_slice_offset(self._slice_offset)
+            v.set_rotation_matrix(R)
+
+    def _on_rotation_delta(self, drx: float, dry: float) -> None:
+        """Gamedev-style Ctrl+drag: apply incremental Rx/Ry rotation from any viewer."""
+        def _wrap(val: float) -> float:
+            while val >  180.0: val -= 360.0
+            while val < -180.0: val += 360.0
+            return val
+
+        for spin, delta in ((self._spin_rx, drx), (self._spin_ry, dry)):
+            spin.blockSignals(True)
+            spin.setValue(_wrap(spin.value() + delta))
+            spin.blockSignals(False)
+
+        self._on_rotation_changed()
+
+    def _sync_preset_buttons(self) -> None:
+        """Check if current angles match a known preset; highlight button if so."""
+        rx = round(self._spin_rx.value(), 1)
+        ry = round(self._spin_ry.value(), 1)
+        rz = round(self._spin_rz.value(), 1)
+        matched = None
+        for name, (prx, pry, prz) in _PRESETS.items():
+            if (rx, ry, rz) == (prx, pry, prz):
+                matched = name
+                break
+        for name, btn in self._preset_buttons.items():
+            btn.blockSignals(True)
+            btn.setChecked(name == matched)
+            btn.blockSignals(False)
+
+    # ── scan initiation ────────────────────────────────────────────────────
+
+    def _on_scan_toggled(self, checked: bool) -> None:
+        if checked:
+            if self._seq_info is None:
+                QMessageBox.warning(
+                    self, "Нет данных",
+                    "Сначала загрузите и экспортируйте последовательность\n"
+                    "во вкладке «Sequence»."
+                )
+                self._btn_scan.setChecked(False)
+                return
+            info = dict(self._seq_info)
+            info["rotation_matrix"] = self._compute_rotation_matrix()
+            info["slice_position"]  = list(self._slice_offset)
+            self._scan_worker = _ScanWorker(self._orchestrator_url, info, parent=self)
+            self._scan_worker.finished.connect(self._on_scan_done)
+            self._scan_worker.error.connect(self._on_scan_error)
+            self._scan_worker.start()
+            self._scan_timer.start()
+            self._btn_scan.setText("⏹  Стоп")
+            self._status_label.setText("Сканирование…")
+            self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
+            for v in self._viewers:
+                v.set_scanning(True)
+        else:
+            self._scan_timer.stop()
+            self._btn_scan.setText("▶  Сканировать")
+            for v in self._viewers:
+                v.set_scanning(False)
+            self._update_scan_ready_state()
+
+    def _on_scan_done(self, msg: str) -> None:
+        self._status_label.setText(f"Готово ({msg})")
+        self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
+        self._btn_scan.setChecked(False)
+
+    def _on_scan_error(self, err: str) -> None:
+        self._status_label.setText(f"Ошибка: {err[:60]}")
+        self._status_label.setStyleSheet("color: #ee4444; font-size: 11px;")
+        self._btn_scan.setChecked(False)
+
+    def _update_scan_ready_state(self) -> None:
+        if self._seq_info is not None:
+            self._status_label.setText("Готово к сканированию")
+            self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
+        else:
+            self._status_label.setText("Нет данных")
+            self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
+
+    # ── protocol selection ─────────────────────────────────────────────────
+
+    def _on_protocol_selected(self, name: str) -> None:
+        self._active_protocol = name

+ 667 - 0
apps/gui/src/tabs/seq_interp_tab.py

@@ -0,0 +1,667 @@
+"""
+Sequence Interpreter tab — ported from seq_interp/src/gui/main_window.py.
+
+Converts the standalone QMainWindow into a self-contained QWidget that can
+be embedded as a tab inside LFMRIWindow.  All interpretation, export, and
+visualisation functionality is preserved unchanged.
+"""
+from __future__ import annotations
+
+import logging
+import os
+from datetime import datetime
+
+from PySide6.QtCore import Qt, QSize, Signal
+from PySide6.QtGui import QFont, QColor
+from PySide6.QtWidgets import (
+    QApplication, QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
+    QGroupBox, QFormLayout, QLabel, QListWidget, QListWidgetItem,
+    QFrame, QPushButton, QProgressBar,
+    QMessageBox, QScrollArea, QSizePolicy, QFileDialog,
+)
+
+from src.gui.adapters import (
+    build_block_rows, seq_metadata, validate_timing, find_block_at_time,
+)
+from src.gui.block_table import BlockTable
+from src.gui.controls_panel import DelayControlsPanel
+from src.gui.plot_panel import PlotPanel
+from src.gui.preview_panel import PreviewPanel
+from src.gui.scheme_panel import SchemePanel, system_is_dark
+from src.gui.workers import (
+    LoadInterpWorker, SyncOnlyWorker, ExportWorker, XmlPreviewWorker,
+    SeqInterpHttpWorker,
+)
+from src.clients.seq_interp_client import SeqInterpClient
+
+# ── loading-state style maps (light / dark) ───────────────────────────────────
+
+_STATE_LIGHT: dict[str, tuple[str, str]] = {
+    "idle":     ("#757575", "●"),
+    "selected": ("#1565c0", "●"),
+    "loading":  ("#e65100", "⟳"),
+    "loaded":   ("#2e7d32", "✓"),
+    "failed":   ("#c62828", "✗"),
+}
+
+_STATE_DARK: dict[str, tuple[str, str]] = {
+    "idle":     ("#9e9e9e", "●"),
+    "selected": ("#64b5f6", "●"),
+    "loading":  ("#ff9800", "⟳"),
+    "loaded":   ("#81c784", "✓"),
+    "failed":   ("#ef9a9a", "✗"),
+}
+
+
+class SeqInterpTab(QWidget):
+    """Full sequence interpreter panel, embeddable as a QTabWidget tab."""
+
+    # Emitted after a successful interpretation so other tabs can react.
+    seq_loaded = Signal(str)
+    # Emitted after export with the info dict for Scanner tab.
+    ready_for_scan = Signal(dict)
+
+    def __init__(
+        self,
+        hw_config_path: str | None = None,
+        output_dir: str | None = None,
+        seq_interp_url: str = "http://localhost:7475",
+        parent: QWidget | None = None,
+    ) -> None:
+        super().__init__(parent)
+
+        # ── application state ──────────────────────────────────────────
+        self._seq_path: str | None = None
+        self._hw_config_path: str | None = hw_config_path
+        self._output_dir: str | None = output_dir
+        self._seq_data: dict | None = None
+        self._sync_data: dict | None = None
+        self._hw = None
+        self._block_rows: list = []
+        self._worker = None
+        self._xml_preview_worker = None
+        self._pending_table_select: int | None = None
+        self._post_info: dict | None = None
+
+        # ── seq_interp HTTP client (used when service is available) ────
+        self._seq_interp_client = SeqInterpClient(seq_interp_url)
+        self._use_service: bool = False   # set to True after successful healthcheck
+
+        # ── build UI ───────────────────────────────────────────────────
+        root_layout = QVBoxLayout(self)
+        root_layout.setContentsMargins(0, 0, 0, 0)
+        root_layout.setSpacing(0)
+        root_layout.addWidget(self._build_button_bar())
+        root_layout.addWidget(self._build_seq_status_bar())
+        root_layout.addWidget(self._build_main_splitter(), stretch=1)
+        root_layout.addWidget(self._build_tab_status_bar())
+
+        self._setup_logging()
+
+    # ================================================================== #
+    #  Public API (called by LFMRIWindow / FidTab)                        #
+    # ================================================================== #
+
+    def set_hw_config(self, path: str) -> None:
+        self._hw_config_path = path
+        self._log(f"HW config: {path}")
+
+    def set_output_dir(self, path: str) -> None:
+        self._output_dir = path
+        self._log(f"Output dir: {path}")
+
+    def load_seq_file(self, path: str) -> None:
+        """Pre-load a sequence path (e.g. generated by FidTab or opened via menu)."""
+        self._seq_path = path
+        name = os.path.basename(path)
+        self._set_seq_state("selected", name)
+        self._btn_run.setEnabled(True)
+        self._log(f"Sequence loaded: {path}")
+
+    # ================================================================== #
+    #  Button bar                                                          #
+    # ================================================================== #
+
+    def _build_button_bar(self) -> QWidget:
+        bar = QWidget()
+        bar.setObjectName("ButtonBar")
+        bar.setStyleSheet(
+            "#ButtonBar { background: palette(window); border-bottom: 1px solid palette(mid); }"
+        )
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(6, 4, 6, 4)
+        lay.setSpacing(4)
+
+        def sep() -> QFrame:
+            f = QFrame()
+            f.setFrameShape(QFrame.VLine)
+            f.setFrameShadow(QFrame.Sunken)
+            f.setFixedWidth(2)
+            return f
+
+        def btn(label: str, tip: str, slot=None, enabled: bool = True) -> QPushButton:
+            b = QPushButton(label)
+            b.setToolTip(tip)
+            b.setEnabled(enabled)
+            b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+            if slot:
+                b.clicked.connect(slot)
+            lay.addWidget(b)
+            return b
+
+        self._btn_load_seq = btn("📂 Load .seq",  "Open Pulseq .seq file",             self._load_seq)
+        self._btn_load_hw  = btn("⚙ HW Config",   "Load hardware constraints JSON",     self._load_hw_config)
+        self._btn_out_dir  = btn("📁 Output Dir",  "Choose output directory",            self._choose_output_dir)
+        lay.addWidget(sep())
+        self._btn_run      = btn("▶ Run",          "Run interpretation pipeline",        self._run,    enabled=False)
+        self._btn_export   = btn("💾 Export",      "Export all artifacts to output dir", self._export, enabled=False)
+        lay.addWidget(sep())
+        self._btn_fit      = btn("🔍 Fit All",     "Fit all plots to data",              lambda: self._plots.fit_all())
+        self._btn_blocks   = btn("📋 Blocks ▼",    "Toggle block table open/closed",     self._toggle_table)
+        lay.addWidget(sep())
+        self._btn_send_scan = btn(
+            "📡 Send to Scanner",
+            "Send exported sequence info to Scanner tab",
+            self._send_to_scanner,
+            enabled=False,
+        )
+        self._btn_send_scan.setVisible(False)
+
+        lay.addStretch()
+
+        self._progress = QProgressBar()
+        self._progress.setRange(0, 0)
+        self._progress.setFixedWidth(120)
+        self._progress.setVisible(False)
+        lay.addWidget(self._progress)
+
+        return bar
+
+    def _build_seq_status_bar(self) -> QWidget:
+        bar = QWidget()
+        bar.setObjectName("SeqStatus")
+        bar.setStyleSheet(
+            "#SeqStatus { background: palette(window); border-bottom: 1px solid palette(mid); }"
+        )
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(8, 2, 8, 2)
+        self._seq_status = QLabel("  ● No file selected")
+        self._seq_status.setFont(QFont("Arial", 9))
+        self._seq_status.setStyleSheet("color: #9e9e9e;")
+        lay.addWidget(self._seq_status)
+        lay.addStretch()
+        return bar
+
+    # ================================================================== #
+    #  Main three-panel splitter                                           #
+    # ================================================================== #
+
+    def _build_main_splitter(self) -> QSplitter:
+        root = QSplitter(Qt.Horizontal)
+
+        root.addWidget(self._build_left_panel())
+        root.addWidget(self._build_centre_panel())
+
+        self._preview = PreviewPanel()
+        self._preview.setMinimumWidth(260)
+        self._preview.setMaximumWidth(480)
+        root.addWidget(self._preview)
+
+        root.setSizes([280, 1040, 280])
+
+        self._plots.blockClicked.connect(self._on_block_from_plot)
+        self._plots.timeHovered.connect(self._on_hover)
+        self._table.blockSelected.connect(self._on_block_from_table)
+        self._scheme.blockClicked.connect(self._on_block_from_scheme)
+        self._controls.rerun.connect(self._rerun)
+        self._controls.reloadConfig.connect(self._reload_hw_config)
+
+        return root
+
+    def _build_left_panel(self) -> QScrollArea:
+        left = QWidget()
+        left.setMinimumWidth(220)
+        left.setMaximumWidth(360)
+        lay = QVBoxLayout(left)
+        lay.setContentsMargins(4, 4, 4, 4)
+        lay.setSpacing(6)
+
+        meta_grp = QGroupBox("Sequence Metadata")
+        meta_form = QFormLayout(meta_grp)
+        meta_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+        self._meta_labels: dict[str, QLabel] = {}
+        for key in [
+            "Total blocks (orig)", "RF blocks", "ADC blocks", "Grad blocks",
+            "RF raster (µs)", "Grad raster (µs)", "ADC raster (ns)",
+            "Block raster (µs)", "RF delay (ns)", "TR delay (ns)",
+            "Start delay (µs)", "Min block dur (ns)", "Gamma (MHz/T)", "RF scale",
+        ]:
+            lbl = QLabel("—")
+            lbl.setFont(QFont("Courier New", 9))
+            meta_form.addRow(QLabel(f"{key}:"), lbl)
+            self._meta_labels[key] = lbl
+        lay.addWidget(meta_grp)
+
+        self._controls = DelayControlsPanel()
+        lay.addWidget(self._controls)
+
+        warn_grp = QGroupBox("Warnings")
+        warn_lay = QVBoxLayout(warn_grp)
+        self._warn_list = QListWidget()
+        self._warn_list.setFont(QFont("Arial", 9))
+        self._warn_list.setMaximumHeight(110)
+        self._warn_list.setStyleSheet(
+            "QListWidget { color: palette(text); background: palette(alternateBase); } "
+            "QListWidget::item { padding: 2px; }"
+        )
+        warn_lay.addWidget(self._warn_list)
+        lay.addWidget(warn_grp)
+
+        lay.addStretch()
+
+        scroll = QScrollArea()
+        scroll.setWidget(left)
+        scroll.setWidgetResizable(True)
+        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        return scroll
+
+    def _build_centre_panel(self) -> QSplitter:
+        vsplit = QSplitter(Qt.Vertical)
+        self._centre_vsplit = vsplit
+
+        self._scheme = SchemePanel()
+        vsplit.addWidget(self._scheme)
+
+        self._plots = PlotPanel()
+        vsplit.addWidget(self._plots)
+
+        self._table_container = QWidget()
+        tc_lay = QVBoxLayout(self._table_container)
+        tc_lay.setContentsMargins(0, 0, 0, 0)
+        self._table = BlockTable()
+        tc_lay.addWidget(self._table)
+        self._table_container.setVisible(False)
+        vsplit.addWidget(self._table_container)
+
+        vsplit.setSizes([64, 700, 0])
+        vsplit.setCollapsible(2, True)
+        return vsplit
+
+    # ================================================================== #
+    #  Tab-level status bar (replaces QMainWindow.statusBar())             #
+    # ================================================================== #
+
+    def _build_tab_status_bar(self) -> QFrame:
+        bar = QFrame()
+        bar.setFrameShape(QFrame.StyledPanel)
+        bar.setFixedHeight(22)
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(6, 0, 6, 0)
+        self._status_lbl = QLabel("Ready")
+        self._status_lbl.setFont(QFont("Arial", 8))
+        lay.addWidget(self._status_lbl)
+        lay.addStretch()
+        return bar
+
+    def _setup_logging(self) -> None:
+        # __file__ is src/tabs/seq_interp_tab.py
+        # parents[0] = tabs/, [1] = src/, [2] = lf_mri_gui/  → log there
+        log_dir = os.path.join(
+            os.path.dirname(os.path.dirname(
+                os.path.dirname(os.path.abspath(__file__))
+            )),
+            "log",
+        )
+        os.makedirs(log_dir, exist_ok=True)
+        ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+        handler = logging.FileHandler(
+            os.path.join(log_dir, f"gui_{ts}.log"), encoding="utf-8"
+        )
+        handler.setFormatter(
+            logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
+        )
+        logging.getLogger().addHandler(handler)
+        logging.getLogger().setLevel(logging.INFO)
+
+    # ================================================================== #
+    #  File / directory actions                                            #
+    # ================================================================== #
+
+    def _load_seq(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Open Pulseq sequence", "",
+            "Pulseq files (*.seq);;All files (*)"
+        )
+        if not path:
+            return
+        self._seq_path = path
+        name = os.path.basename(path)
+        self._set_seq_state("selected", name)
+        self._btn_run.setEnabled(True)
+        self._log(f"Sequence selected: {path}")
+
+    def _load_hw_config(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Open HW config", "",
+            "JSON files (*.json);;All files (*)"
+        )
+        if not path:
+            return
+        self._hw_config_path = path
+        self._log(f"HW config: {path}")
+
+    def _choose_output_dir(self) -> None:
+        path = QFileDialog.getExistingDirectory(self, "Choose output directory")
+        if path:
+            self._output_dir = path
+            self._log(f"Output dir: {path}")
+
+    def _reload_hw_config(self) -> None:
+        if self._hw and self._hw_config_path:
+            self._hw.load_from_json(self._hw_config_path)
+            self._controls.load_from_hw(self._hw)
+            self._log("HW config reloaded from file")
+        elif self._hw:
+            from src.hardware.constraints import HardwareConstraints
+            self._hw = HardwareConstraints()
+            self._controls.load_from_hw(self._hw)
+            self._log("HW config reset to class defaults")
+
+    # ================================================================== #
+    #  Table toggle                                                        #
+    # ================================================================== #
+
+    def _toggle_table(self) -> None:
+        visible = not self._table_container.isVisible()
+        self._table_container.setVisible(visible)
+        self._btn_blocks.setText("📋 Blocks ▲" if visible else "📋 Blocks ▼")
+        if visible:
+            sizes = self._centre_vsplit.sizes()
+            if sizes[2] < 100:
+                target  = 200
+                plots_h = max(100, sizes[1] - target)
+                self._centre_vsplit.setSizes([sizes[0], plots_h, target])
+            if self._pending_table_select is not None:
+                self._table.select_by_sync_index(self._pending_table_select)
+                self._pending_table_select = None
+
+    # ================================================================== #
+    #  Run / rerun / export                                                #
+    # ================================================================== #
+
+    def _run(self) -> None:
+        if not self._seq_path:
+            return
+        name = os.path.basename(self._seq_path)
+        self._set_seq_state("loading", name)
+
+        # Try HTTP service first; fall back to local pipeline if unavailable.
+        if self._seq_interp_client.healthcheck():
+            self._use_service = True
+            self._start_busy("Sending to seq_interp service…")
+            self._worker = SeqInterpHttpWorker(
+                self._seq_interp_client, self._seq_path
+            )
+            self._worker.progress.connect(self._log)
+            self._worker.finished.connect(self._on_http_interp_finished)
+            self._worker.error.connect(self._on_worker_error)
+            self._worker.start()
+        else:
+            self._use_service = False
+            self._start_busy("Loading and interpreting (local)…")
+            overrides = self._controls.get_overrides() if self._hw else {}
+            self._worker = LoadInterpWorker(
+                self._seq_path,
+                hw_config_path=self._hw_config_path,
+                hw_overrides=overrides,
+            )
+            self._worker.log_msg.connect(self._log)
+            self._worker.finished.connect(self._on_interp_finished)
+            self._worker.error.connect(self._on_worker_error)
+            self._worker.start()
+
+    def _rerun(self) -> None:
+        if self._seq_data is None:
+            self._run()
+            return
+        self._start_busy("Re-synchronizing…")
+        self._worker = SyncOnlyWorker(
+            self._seq_data,
+            hw_config_path=self._hw_config_path,
+            hw_overrides=self._controls.get_overrides(),
+        )
+        self._worker.log_msg.connect(self._log)
+        self._worker.finished.connect(self._on_sync_finished)
+        self._worker.error.connect(self._on_worker_error)
+        self._worker.start()
+
+    def _export(self) -> None:
+        if self._seq_data is None or self._sync_data is None:
+            return
+        out = self._output_dir
+        if not out:
+            out = QFileDialog.getExistingDirectory(self, "Choose output directory")
+            if not out:
+                return
+            self._output_dir = out
+        self._start_busy("Exporting artifacts…")
+        self._worker = ExportWorker(
+            self._seq_data, self._sync_data, self._hw, out
+        )
+        self._worker.log_msg.connect(self._log)
+        self._worker.finished.connect(self._on_export_finished)
+        self._worker.error.connect(self._on_worker_error)
+        self._worker.start()
+
+    # ================================================================== #
+    #  Worker callbacks                                                    #
+    # ================================================================== #
+
+    def _on_http_interp_finished(self, result: dict) -> None:
+        """Handle result from SeqInterpHttpWorker (service mode)."""
+        self._stop_busy()
+        try:
+            post_info = result.get("post_json", {})
+            self._post_info = post_info.get("info", post_info)
+            xml_text = result.get("xml_text", "")
+            post_text = __import__("json").dumps(post_info, indent=2, default=str)
+            self._preview.set_xml_text(xml_text)
+            self._preview.set_post_json_text(post_text)
+
+            meta = result.get("metadata", {})
+            name = os.path.basename(self._seq_path or "")
+            parts = [
+                f"{meta.get('block_count', '?')} blocks",
+                f"{meta.get('sync_block_count', '?')} sync",
+                f"{meta.get('total_duration_ms', '?')} ms",
+            ]
+            self._set_seq_state("loaded", name, " · ".join(str(p) for p in parts))
+            self._status_lbl.setText(
+                f"{meta.get('block_count','?')} input blocks → "
+                f"{meta.get('sync_block_count','?')} sync blocks"
+            )
+            self._act_enabled(run=True, export=False)
+            if self._post_info:
+                self._btn_send_scan.setVisible(True)
+                self._btn_send_scan.setEnabled(True)
+            if self._seq_path:
+                self.seq_loaded.emit(self._seq_path)
+            self._log(f"Service interpretation complete. "
+                      f"Output: {result.get('output_dir', '—')}")
+        except Exception as exc:
+            self._on_worker_error(f"Result parse error: {exc}")
+
+    def _on_interp_finished(self, seq_data: dict, sync_data: dict, hw) -> None:
+        self._seq_data  = seq_data
+        self._sync_data = sync_data
+        self._hw        = hw
+        self._stop_busy()
+        self._apply_results(seq_data, sync_data, hw)
+        if self._seq_path:
+            self.seq_loaded.emit(self._seq_path)
+
+    def _on_sync_finished(self, sync_data: dict, hw) -> None:
+        self._sync_data = sync_data
+        self._hw        = hw
+        self._stop_busy()
+        self._apply_results(self._seq_data, sync_data, hw)
+
+    def _on_export_finished(self, output_dir: str, xml_text: str,
+                             post_text: str) -> None:
+        self._stop_busy()
+        self._preview.set_xml_text(xml_text)
+        self._preview.set_post_json_text(post_text)
+        self._log(f"Export complete → {output_dir}")
+        try:
+            import json as _json
+            payload = _json.loads(post_text)
+            self._post_info = payload.get("info", payload)
+        except Exception:
+            self._post_info = None
+        if self._post_info:
+            self._btn_send_scan.setVisible(True)
+            self._btn_send_scan.setEnabled(True)
+        QMessageBox.information(
+            self, "Export complete", f"Artifacts written to:\n{output_dir}"
+        )
+
+    def _send_to_scanner(self) -> None:
+        if self._post_info:
+            self.ready_for_scan.emit(self._post_info)
+
+    def _on_worker_error(self, msg: str) -> None:
+        name = os.path.basename(self._seq_path or "")
+        self._set_seq_state("failed", name, msg[:80])
+        self._stop_busy()
+        self._log(f"ERROR: {msg}", error=True)
+        self._preview.add_error(msg)
+        QMessageBox.critical(self, "Error", msg)
+
+    # ================================================================== #
+    #  Results display                                                     #
+    # ================================================================== #
+
+    def _apply_results(self, seq_data: dict, sync_data: dict, hw) -> None:
+        meta = seq_metadata(seq_data, hw)
+        for key, lbl in self._meta_labels.items():
+            lbl.setText(str(meta.get(key, "—")))
+
+        if not self._controls._defaults:
+            self._controls.load_from_hw(hw)
+
+        warnings = validate_timing(hw, seq_data, sync_data)
+        self._refresh_warnings(warnings)
+        self._preview.set_warnings(warnings)
+
+        self._block_rows = build_block_rows(seq_data, sync_data)
+        self._table.load_rows(self._block_rows)
+        self._scheme.load_rows(self._block_rows)
+
+        self._plots.plot_all(seq_data, sync_data)
+
+        pw = XmlPreviewWorker(sync_data, hw)
+        pw.finished.connect(
+            lambda xml, post: (
+                self._preview.set_xml_text(xml),
+                self._preview.set_post_json_text(post),
+            )
+        )
+        pw.error.connect(lambda e: self._log(f"XML preview: {e}", error=True))
+        pw.start()
+        self._xml_preview_worker = pw
+
+        blocks = seq_data.get("blocks", [])
+        total_s = sum(sync_data.get("blocks_duration", []))
+        parts = [f"{len(blocks)} blocks"]
+        if any("RF"   in b.get("type", []) for b in blocks):
+            parts.append("RF")
+        if any(b.get("has_adc") for b in blocks):
+            parts.append("ADC")
+        if any("GRAD" in b.get("type", []) for b in blocks):
+            parts.append("Grad")
+        if total_s >= 1e-3:
+            parts.append(f"{total_s * 1e3:.2f} ms")
+        else:
+            parts.append(f"{total_s * 1e6:.1f} µs")
+        name = os.path.basename(self._seq_path or "")
+        self._set_seq_state("loaded", name, " · ".join(parts))
+
+        self._act_enabled(run=True, export=True)
+        self._status_lbl.setText(
+            f"{len(blocks)} input blocks → {sync_data['number_of_blocks']} sync blocks"
+        )
+
+    def _refresh_warnings(self, warnings: list[str]) -> None:
+        self._warn_list.clear()
+        warn_color = QColor("#ff9800" if system_is_dark() else "#e65100")
+        for w in warnings:
+            item = QListWidgetItem(f"⚠  {w}")
+            item.setForeground(warn_color)
+            self._warn_list.addItem(item)
+
+    # ================================================================== #
+    #  Block selection sync                                                #
+    # ================================================================== #
+
+    def _on_block_from_plot(self, sync_index: int) -> None:
+        self._select_block(sync_index, source="plot")
+
+    def _on_block_from_table(self, sync_index: int) -> None:
+        self._select_block(sync_index, source="table")
+
+    def _on_block_from_scheme(self, sync_index: int) -> None:
+        self._select_block(sync_index, source="scheme")
+
+    def _select_block(self, sync_index: int, source: str) -> None:
+        row = self._table.row_for_sync_index(sync_index)
+        self._preview.show_block_details(row)
+        self._scheme.select_block(sync_index)
+
+        if source != "plot":
+            self._plots.highlight_block(sync_index)
+
+        if self._table_container.isVisible():
+            if source != "table":
+                self._table.select_by_sync_index(sync_index)
+        else:
+            self._pending_table_select = sync_index
+
+    # ================================================================== #
+    #  Status / log helpers                                                #
+    # ================================================================== #
+
+    def _set_seq_state(self, state: str, name: str = "",
+                       detail: str = "") -> None:
+        _state_map = _STATE_DARK if system_is_dark() else _STATE_LIGHT
+        color, icon = _state_map.get(state, ("#9e9e9e", "●"))
+        text = f"  {icon}  {name}" if name else f"  {icon}  No file selected"
+        if detail:
+            text += f"  —  {detail}"
+        self._seq_status.setStyleSheet(f"color: {color}; font-weight: bold;")
+        self._seq_status.setText(text)
+
+    def _on_hover(self, t_s: float, channel: str, value: float) -> None:
+        block = find_block_at_time(t_s, self._block_rows) if self._block_rows else None
+        blk = f"  block #{block.sync_index} [{block.block_type}]" if block else ""
+        self._status_lbl.setText(
+            f"t = {t_s * 1e6:.4f} µs   {channel} = {value:.4g}{blk}"
+        )
+
+    def _log(self, msg: str, error: bool = False) -> None:
+        self._preview.append_log(msg, error=error)
+        if error:
+            logging.error(msg)
+        else:
+            logging.info(msg)
+
+    def _start_busy(self, tip: str) -> None:
+        self._progress.setVisible(True)
+        self._status_lbl.setText(tip)
+        self._act_enabled(run=False, export=False)
+
+    def _stop_busy(self) -> None:
+        self._progress.setVisible(False)
+
+    def _act_enabled(self, run: bool, export: bool) -> None:
+        self._btn_run.setEnabled(run and bool(self._seq_path))
+        self._btn_export.setEnabled(export and self._seq_data is not None)

+ 0 - 0
apps/gui/src/utils/__init__.py


+ 15 - 0
apps/gui/src/utils/cumsum.py

@@ -0,0 +1,15 @@
+def cumsum(a, b, c=None, d=None, e=None):
+    if e is not None:
+        s1 = a + b
+        s2 = s1 + c
+        s3 = s2 + d
+        return a, s1, s2, s3, s3 + e
+    elif d is not None:
+        s1 = a + b
+        s2 = s1 + c
+        return a, s1, s2, s2 + d
+    elif c is not None:
+        s = a + b
+        return a, s, s + c
+    else:
+        return a, a + b

+ 8 - 0
apps/gui/src/utils/dataclass.py

@@ -0,0 +1,8 @@
+from dataclasses import dataclass
+@dataclass
+class TTLBlock:
+    RF: int = 0
+    SW: int = 0
+    ADC: int = 0
+    GR: int = 1
+    duration: int = 0  # в тиках

+ 117 - 0
apps/gui/src/utils/vizualizator.py

@@ -0,0 +1,117 @@
+import matplotlib.pyplot as plt
+import numpy as np
+from typing import List
+from ..utils.dataclass import TTLBlock
+from ..hardware.constraints import HardwareConstraints
+
+
+def plot_ttl_timeline(
+        ttl_blocks: List[TTLBlock],
+        original_blocks: List[dict],
+        tick_duration: float = 20e-9
+):
+    """
+    Визуализирует TTL timeline + оригинальные сигналы:
+    RF и ADC отображаются как синусоиды, GRAD как трапеция.
+    Оригинальные сигналы сдвигаются вправо с учётом начального TTL-блока.
+
+    :param ttl_blocks: список TTLBlock объектов (результат Synchronizer)
+    :param original_blocks: список исходных блоков {"type": [...], "duration": float}
+    :param tick_duration: длительность одного тика (сек)
+    """
+    # === TTL timeline ===
+    time = 0
+    ttl_stream = {"RF": [], "SW": [], "ADC": [], "GR": []}
+    time_stream = []
+
+    for block in ttl_blocks:
+        for _ in range(block.duration):
+            time_stream.append(time)
+            ttl_stream["RF"].append(block.RF)
+            ttl_stream["SW"].append(block.SW)
+            ttl_stream["ADC"].append(block.ADC)
+            ttl_stream["GR"].append(block.GR)
+            time += tick_duration
+
+    # === Исходные сигналы (RF, ADC — синус; GRAD — трапеция) ===
+    signal_plot = {"RF": [], "ADC": [], "GRAD": []}
+    signal_time = []
+    t = 0
+
+    for block in original_blocks:
+        duration = block["duration"]
+        ticks = int(duration / tick_duration)
+        t_block = np.linspace(t, t + duration, ticks)
+
+        # RF синус
+        if "RF" in block["type"]:
+            signal_plot["RF"].extend(np.sin(2 * np.pi * 20e6 * t_block))  # 20 кГц
+        else:
+            signal_plot["RF"].extend([0] * ticks)
+
+        # ADC синус, меньшей амплитудой
+        if "ADC" in block["type"]:
+            signal_plot["ADC"].extend(0.5 * np.sin(2 * np.pi * 10e6 * t_block))
+        else:
+            signal_plot["ADC"].extend([0] * ticks)
+
+        # GRAD — трапеция
+        if "GRAD" in block["type"]:
+            ramp_len = ticks // 4
+            plateau_len = ticks // 2
+            trapezoid = list(np.linspace(0, 1, ramp_len)) + \
+                        [1] * plateau_len + \
+                        list(np.linspace(1, 0, ticks - ramp_len - plateau_len))
+            signal_plot["GRAD"].extend(trapezoid)
+        else:
+            signal_plot["GRAD"].extend([0] * ticks)
+
+        signal_time.extend(t_block)
+        t += duration
+    hw = HardwareConstraints()
+    # === Сдвиг исходных сигналов на START_DELAY ===
+    start_offset = tick_duration * (
+            round(hw.START_DELAY / tick_duration) +
+            max(
+                round(hw.RF_DELAY / tick_duration),
+                round(hw.TR_DELAY / tick_duration),
+                round(hw.GRAD_DELAY / tick_duration)
+            )
+    )
+    adjusted_signal_time = [t + start_offset for t in signal_time]
+
+    # === Визуализация ===
+    plt.figure(figsize=(14, 8))
+
+    # TTL сигналы — логика
+    for i, (label, values) in enumerate(ttl_stream.items()):
+        plt.step(
+            [t * 1e3 for t in time_stream],
+            [v + i * 2 for v in values],
+            where='post',
+            label=f"TTL: {label}"
+        )
+
+    # Исходные сигналы (синус/трапеция)
+    offset_map = {"RF": 8, "ADC": 10, "GRAD": 12}
+    color_map = {"RF": "blue", "ADC": "purple", "GRAD": "orange"}
+
+    for label in signal_plot:
+        y = np.array(signal_plot[label]) + offset_map[label]
+        plt.plot(
+            [t * 1e3 for t in adjusted_signal_time],
+            y,
+            label=f"Signal: {label}",
+            color=color_map[label]
+        )
+
+    # Финальные штрихи
+    plt.title("TTL Timeline + Original Signal Shapes (с учётом задержек)")
+    plt.xlabel("Time (mks)")
+    yticks = [i * 2 for i in range(4)] + list(offset_map.values())
+    ylabels = list(ttl_stream.keys()) + list(offset_map.keys())
+    plt.yticks(yticks, ylabels)
+    plt.grid(True)
+    plt.legend(loc="upper right")
+    plt.tight_layout()
+    plt.show()

+ 32 - 63
docker-compose.yml

@@ -3,18 +3,8 @@ services:
   # ── Orchestration workflow engine ─────────────────────────────────────────
   orchestrator:
     build:
-      context: ../lf_orchestration
-      dockerfile_inline: |
-        FROM python:3.12-slim
-        WORKDIR /app
-        ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
-        RUN apt-get update && apt-get install -y --no-install-recommends curl \
-            && rm -rf /var/lib/apt/lists/*
-        COPY requirements.txt .
-        RUN pip install --no-cache-dir -r requirements.txt
-        COPY . .
-        EXPOSE 1717
-        CMD ["uvicorn", "orchestrator.main:app", "--host", "0.0.0.0", "--port", "1717"]
+      context: ./services/orchestrator
+      dockerfile: Dockerfile
     image: lf-orchestrator:${ORCHESTRATOR_VERSION:-dev}
     container_name: lf-orchestrator
     ports:
@@ -36,10 +26,12 @@ services:
       start_period: 15s
 
   # ── MRI sequence interpreter ───────────────────────────────────────────────
+  # Build context is the monorepo root so Dockerfile can access both
+  # services/seq-interp/ and libs/lf-scanner/
   seq-interp:
     build:
-      context: ../lf_mri/MRI-testing
-      dockerfile: seq_interp/Dockerfile
+      context: .
+      dockerfile: services/seq-interp/Dockerfile
     image: lf-seq-interp:${SEQ_INTERP_VERSION:-dev}
     container_name: lf-seq-interp
     ports:
@@ -56,39 +48,30 @@ services:
       retries: 5
       start_period: 15s
 
-  # ── Spectroscopy signal processor ─────────────────────────────────────────
-  spectroscopy:
+  # ── Spectrometer hardware controller (DRF) ────────────────────────────────
+  spectrometer:
     build:
-      context: ../lf_mri/fast-api-spectroscopy
-    image: lf-spectroscopy:${SPECTROSCOPY_VERSION:-dev}
-    container_name: lf-spectroscopy
+      context: ./services/spectrometer
+      dockerfile: Dockerfile
+    image: lf-spectrometer:${SPECTROMETER_VERSION:-dev}
+    container_name: lf-spectrometer
     ports:
-      - "${SPECTROSCOPY_PORT:-8002}:8002"
-    environment:
-      SERVICE_PORT: "8002"
+      - "${SPECTROMETER_PORT:-8000}:8000"
+    volumes:
+      - spectrometer_db:/app/db
     restart: unless-stopped
     healthcheck:
-      test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
-      interval: 10s
+      test: ["CMD", "curl", "-f", "http://localhost:8000/api/"]
+      interval: 15s
       timeout: 5s
       retries: 5
-      start_period: 15s
+      start_period: 30s
 
   # ── MRI image reconstructor ────────────────────────────────────────────────
   reconstructor:
     build:
-      context: ../fast-api-reconstruction/serv
-      dockerfile_inline: |
-        FROM python:3.12-slim
-        WORKDIR /app
-        ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app MPLBACKEND=Agg
-        RUN apt-get update && apt-get install -y --no-install-recommends curl libgomp1 \
-            && rm -rf /var/lib/apt/lists/*
-        COPY requirements.txt .
-        RUN pip install --no-cache-dir -r requirements.txt
-        COPY . .
-        EXPOSE 8000
-        CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
+      context: ./services/reconstructor
+      dockerfile: Dockerfile
     image: lf-reconstructor:${RECONSTRUCTOR_VERSION:-dev}
     container_name: lf-reconstructor
     ports:
@@ -103,38 +86,24 @@ services:
       retries: 5
       start_period: 20s
 
-  # ── Spectrometer hardware controller (DRF) ────────────────────────────────
-  spectrometer:
+  # ── Spectroscopy signal processor ─────────────────────────────────────────
+  spectroscopy:
     build:
-      context: ../lowfield_mri_programs/spectrometer_service/mserv00
-      dockerfile_inline: |
-        FROM python:3.12-slim
-        WORKDIR /app
-        ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
-            DJANGO_SETTINGS_MODULE=mserv00.settings \
-            DJANGO_ALLOWED_HOSTS=*
-        RUN apt-get update && apt-get install -y --no-install-recommends curl \
-            && rm -rf /var/lib/apt/lists/*
-        COPY requirements.txt .
-        RUN pip install --no-cache-dir -r requirements.txt
-        COPY . .
-        RUN sed -i "s/ALLOWED_HOSTS = \[.*/ALLOWED_HOSTS = ['*']/" mserv00/settings.py \
-            && python manage.py migrate --noinput
-        EXPOSE 8000
-        CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
-    image: lf-spectrometer:${SPECTROMETER_VERSION:-dev}
-    container_name: lf-spectrometer
+      context: ./services/spectroscopy
+      dockerfile: Dockerfile
+    image: lf-spectroscopy:${SPECTROSCOPY_VERSION:-dev}
+    container_name: lf-spectroscopy
     ports:
-      - "${SPECTROMETER_PORT:-8000}:8000"
-    volumes:
-      - spectrometer_db:/app/db
+      - "${SPECTROSCOPY_PORT:-8002}:8002"
+    environment:
+      SERVICE_PORT: "8002"
     restart: unless-stopped
     healthcheck:
-      test: ["CMD", "curl", "-f", "http://localhost:8000/api/"]
-      interval: 15s
+      test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
+      interval: 10s
       timeout: 5s
       retries: 5
-      start_period: 30s
+      start_period: 15s
 
 volumes:
   seq_interp_input:

+ 37 - 52
install.ps1

@@ -4,12 +4,13 @@
 #  Requires: Windows 10/11, PowerShell 5.1+, internet access
 # ==============================================================================
 param(
-    [string]$PythonExe = "python",
-    [string]$GuiDir    = "..\lf_mri\MRI-testing\lf_mri_gui",
-    [string]$RepoRoot  = "..\lf_mri\MRI-testing"
+    [string]$PythonExe = "python"
 )
 
 $ErrorActionPreference = "Stop"
+$Root    = $PSScriptRoot
+$GuiDir  = Join-Path $Root "apps\gui"
+$LibsDir = Join-Path $Root "libs\lf-scanner"
 
 function Write-Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
 function Write-OK($msg)   { Write-Host "    [OK] $msg" -ForegroundColor Green }
@@ -19,41 +20,30 @@ function Write-Fail($msg) { Write-Host "    [FAIL] $msg" -ForegroundColor Red; e
 # ── 1. Check prerequisites ────────────────────────────────────────────────────
 Write-Step "Checking prerequisites"
 
-# Python
 try {
     $pyVer = & $PythonExe --version 2>&1
-    if ($pyVer -match "3\.(1[0-9]|[0-9]+)") {
-        Write-OK "Python: $pyVer"
-    } else {
-        Write-Fail "Python 3.10+ required, found: $pyVer"
-    }
-} catch {
-    Write-Fail "Python not found. Install from https://python.org (add to PATH)"
-}
+    if ($pyVer -match "3\.(1[0-9]|[0-9]+)") { Write-OK "Python: $pyVer" }
+    else { Write-Fail "Python 3.10+ required, found: $pyVer" }
+} catch { Write-Fail "Python not found. Install from https://python.org (add to PATH)" }
 
-# Docker
 try {
     $dockerVer = & docker --version 2>&1
     Write-OK "Docker: $dockerVer"
 } catch {
-    Write-Warn "Docker not found — services (orchestrator, seq_interp, etc.) won't start."
+    Write-Warn "Docker not found — backend services won't start without it."
     Write-Warn "Install Docker Desktop: https://www.docker.com/products/docker-desktop/"
 }
 
-# Docker Compose
 try {
     $composeVer = & docker compose version 2>&1
     Write-OK "Docker Compose: $composeVer"
-} catch {
-    Write-Warn "Docker Compose not found (included in Docker Desktop >= 3.0)"
-}
+} catch { Write-Warn "Docker Compose not found (included in Docker Desktop >= 3.0)" }
 
 # ── 2. Create Python virtual environment for the GUI ─────────────────────────
-Write-Step "Creating Python virtual environment"
+Write-Step "Creating Python virtual environment for GUI"
 
 $venvPath = Join-Path $GuiDir ".venv"
-$absGuiDir = Resolve-Path $GuiDir -ErrorAction SilentlyContinue
-if (-not $absGuiDir) { Write-Fail "GUI directory not found: $GuiDir" }
+if (-not (Test-Path $GuiDir)) { Write-Fail "GUI directory not found: $GuiDir" }
 
 if (Test-Path $venvPath) {
     Write-Warn "Venv already exists at $venvPath — skipping creation"
@@ -67,34 +57,28 @@ $pip = Join-Path $venvPath "Scripts\pip.exe"
 # ── 3. Install GUI dependencies ───────────────────────────────────────────────
 Write-Step "Installing GUI dependencies"
 
-# Install root requirements first (numpy, scipy, etc.)
-$rootReqs = Join-Path $RepoRoot "requirements.txt"
-if (Test-Path $rootReqs) {
-    & $pip install -r $rootReqs --quiet
-    Write-OK "Root requirements installed"
-}
-
-# Install GUI-specific requirements
 $guiReqs = Join-Path $GuiDir "requirements.txt"
 if (Test-Path $guiReqs) {
     & $pip install -r $guiReqs --quiet
     Write-OK "GUI requirements installed"
 }
 
-# Install LF_scanner as editable package
-$lfScannerSetup = Join-Path $RepoRoot "LF_scanner\setup.py"
-if (Test-Path $lfScannerSetup) {
-    & $pip install -e (Join-Path $RepoRoot "LF_scanner") --quiet
-    Write-OK "LF_scanner installed (editable)"
+# Install lf-scanner as an editable package
+$lfScannerPyproject = Join-Path $LibsDir "pyproject.toml"
+$lfScannerSetup     = Join-Path $LibsDir "setup.py"
+
+if (Test-Path $lfScannerPyproject -or (Test-Path $lfScannerSetup)) {
+    & $pip install -e $LibsDir --quiet
+    Write-OK "lf-scanner installed (editable)"
 } else {
-    Write-Warn "LF_scanner/setup.py not found — skipping"
+    Write-Warn "lf-scanner has no pyproject.toml/setup.py — adding to PYTHONPATH only"
 }
 
 # ── 4. Copy .env for Docker services ─────────────────────────────────────────
 Write-Step "Setting up Docker environment config"
 
-$envFile    = Join-Path $PSScriptRoot ".env"
-$envExample = Join-Path $PSScriptRoot ".env.example"
+$envFile    = Join-Path $Root ".env"
+$envExample = Join-Path $Root ".env.example"
 
 if (-not (Test-Path $envFile)) {
     Copy-Item $envExample $envFile
@@ -103,29 +87,29 @@ if (-not (Test-Path $envFile)) {
     Write-Warn ".env already exists — not overwriting"
 }
 
-# ── 5. Create desktop shortcut ────────────────────────────────────────────────
+# ── 5. Create desktop shortcuts ────────────────────────────────────────────────
 Write-Step "Creating desktop shortcuts"
 
-$desktopPath = [Environment]::GetFolderPath("Desktop")
+$desktopPath   = [Environment]::GetFolderPath("Desktop")
 $pythonExePath = Join-Path $venvPath "Scripts\python.exe"
-$appScript = Resolve-Path (Join-Path $GuiDir "app.py")
+$appScript     = Join-Path $GuiDir "app.py"
 
-# Shortcut: Start GUI only
 $wsh = New-Object -ComObject WScript.Shell
+
+# GUI only
 $sc = $wsh.CreateShortcut("$desktopPath\LF-MRI GUI.lnk")
-$sc.TargetPath  = $pythonExePath
-$sc.Arguments   = "`"$appScript`""
-$sc.WorkingDirectory = (Resolve-Path $RepoRoot).Path
-$sc.Description = "LF-MRI System GUI"
+$sc.TargetPath       = $pythonExePath
+$sc.Arguments        = "`"$appScript`""
+$sc.WorkingDirectory = $Root
+$sc.Description      = "LF-MRI System GUI"
 $sc.Save()
 Write-OK "Shortcut: 'LF-MRI GUI.lnk' on Desktop"
 
-# Shortcut: Start all (services + GUI)
-$startAll = Resolve-Path (Join-Path $PSScriptRoot "start.ps1")
+# Start all
 $scAll = $wsh.CreateShortcut("$desktopPath\LF-MRI Start All.lnk")
 $scAll.TargetPath       = "powershell.exe"
-$scAll.Arguments        = "-ExecutionPolicy Bypass -File `"$startAll`""
-$scAll.WorkingDirectory = $PSScriptRoot
+$scAll.Arguments        = "-ExecutionPolicy Bypass -File `"$Root\start.ps1`""
+$scAll.WorkingDirectory = $Root
 $scAll.Description      = "Start LF-MRI services + GUI"
 $scAll.Save()
 Write-OK "Shortcut: 'LF-MRI Start All.lnk' on Desktop"
@@ -137,9 +121,10 @@ Write-Host "  Installation complete!" -ForegroundColor Green
 Write-Host "============================================================" -ForegroundColor Green
 Write-Host ""
 Write-Host "  Next steps:"
-Write-Host "  1. Start all services + GUI:  .\start.ps1"
-Write-Host "  2. GUI only (no services):    .\start.ps1 -GuiOnly"
-Write-Host "  3. Stop services:             .\stop.ps1"
+Write-Host "  1. Start all services + GUI:   .\start.ps1"
+Write-Host "  2. GUI only (no services):     .\start.ps1 -GuiOnly"
+Write-Host "  3. Hardware mode:              .\start.ps1 -Mode real"
+Write-Host "  4. Stop services:              .\stop.ps1"
 Write-Host ""
 Write-Host "  Config: edit .env to change ports or switch mode (plug/real)"
 Write-Host ""

+ 74 - 0
libs/lf-scanner/CODE_OF_CONDUCT.md

@@ -0,0 +1,74 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, gender identity and expression, level of experience,
+nationality, personal appearance, race, religion, or sexual identity and
+orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+  address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the team at <imr-framework2018@gmail.com>. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at [http://contributor-covenant.org/version/1/4][version]
+
+[homepage]: http://contributor-covenant.org
+[version]: http://contributor-covenant.org/version/1/4/

+ 30 - 0
libs/lf-scanner/CONTRIBUTING.md

@@ -0,0 +1,30 @@
+# Contributing to `pypulseq`
+:thumbsup: :tada: Thanks for taking time to contribute! :thumbsup: :tada:
+
+Here are guidelines (not rules!) for contributing to `pypulseq`. Use your best judgment, and feel free to propose 
+changes to this document in a pull request.
+
+## Table of contents
+1. [Code of Conduct](#code-of-conduct)
+2. [PEP Style Guide for Python coding](#style-guide-for-python-code)
+
+## Code of Conduct
+This project and everyone participating in it is governed by the 
+[`pypulseq` Code of Conduct][code_of_conduct].
+By participating, you are expected to uphold this code. Please report unacceptable behavior to 
+[imr.framework2018@gmail.com][email].
+
+## Pull requests
+Follow the coding conventions laid out in the [Style Guide for Python Code](style_guide). Ensure source code is 
+documented as per the Numpy convention [[numpy1]], [[numpy2]]. If you notice any `pypulseq` code not adhering to 
+[PEP8](style-guide), submit a pull request or open an issue.
+
+## Issues
+Please adhere to the appropriate templates when reporting bugs or requesting features. The templates are automatically 
+presented via Github's 'New Issue' feature.
+
+[email]: mailto:imr.framework2018@gmail.com
+[code_of_conduct]: https://github.com/imr-framework/pypulseq/blob/master/CODE_OF_CONDUCT.md
+[style_guide]: https://www.python.org/dev/peps/pep-0008/
+[numpy1]: https://numpydoc.readthedocs.io/en/latest/format.html
+[numpy2]: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html

File diff suppressed because it is too large
+ 102 - 0
libs/lf-scanner/FID_from_scratch.ipynb


+ 661 - 0
libs/lf-scanner/LICENSE

@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published
+    by the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.

+ 3 - 0
libs/lf-scanner/MANIFEST.in

@@ -0,0 +1,3 @@
+include VERSION
+include pypulseq/SAR/QGlobal.mat
+recursive-include pypulseq/seq_examples/* *

+ 201 - 0
libs/lf-scanner/README.md

@@ -0,0 +1,201 @@
+<p align="center">
+
+![PyPulseq](logo.png)
+
+</p>
+
+# PyPulseq: A Python Package for MRI Pulse Sequence Design
+
+`Compatible with Pulseq 1.4.0`
+
+
+🚨🚨🚨 **NOTE:** This is the `dev` branch which hosts the bleeding edge version. For the most recent, stable release,
+switch to the `master` branch by clicking [here](https://github.com/imr-framework/pypulseq/tree/master). 🚨🚨🚨
+
+## Table of contents 🧾
+1. [👥 Contributors][section-contributors]
+2. [📚 Citations][section-relevant-literature]
+3. [🔨 Installation][section-installation]
+4. [⚡ Lightning-start - PyPulseq in your browser!][section-lightning-start]
+5. [🏃‍♂ Quickstart - example scripts][section-quickstart-examples]
+6. [🤿 Deep dive - custom pulse sequences][section-deep-dive]
+7. [👥 Contributing and Community guidelines][section-contributing]
+8. [📖 References][section-references]
+9. [📃 API documentation][api-docs]
+
+---
+
+Pulse sequence design is a significant component of MRI research. However, multi-vendor studies require researchers to
+be acquainted with each hardware platform's programming environment.
+
+PyPulseq enables vendor-neutral pulse sequence design in Python [[1,2]][section-references]. The pulse sequences can be
+exported as a `.seq` file to be run on  Siemens/[GE]/[Bruker] hardware by leveraging their respective
+Pulseq interpreters. This tool is targeted at MRI pulse sequence designers, researchers, students and other interested
+users. It is a translation of the Pulseq framework originally written in Matlab [[3]][section-references].
+
+👉 Currently, PyPulseq is compatible with Pulseq 1.4.0. 👈
+
+It is strongly recommended to first read the [Pulseq specification]  before proceeding. The specification
+document defines the concepts required for pulse sequence design using PyPulseq.
+
+If you use PyPulseq in your work, cite the following publications:
+```
+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.
+
+Ravi, Keerthi Sravan, et al. "Pulseq-Graphical Programming Interface: Open source visual environment for prototyping
+pulse sequences and integrated magnetic resonance imaging algorithm development." Magnetic resonance imaging 52 (2018):
+9-15.
+```
+
+## 📢 Pulse sequence development in your browser!
+Design pulse sequences using `pypulseq` in your browser! Check out the [⚡ Lightning-start][section-lightning-start] section to
+learn how!
+
+## 1. 👥 Contributors (alphabetical)
+- @bilal-tasdelen
+- @calderds
+- @mavel101
+- @nnmurthy
+- @sairamgeethanath
+- @schuenke
+- @skarrea
+- @tonggehua
+
+Please email me/submit PR/open an issue if any contributors are missing.
+
+## 2. 📚 [Citations][scholar-citations] (reverse chronological)
+1. Hennig, J., Barghoorn, A., Zhang, S. and Zaitsev, M., 2022. Single shot spiral TSE with annulated segmentation.
+Magnetic Resonance in Medicine.
+2. Niso, G., Botvinik-Nezer, R., Appelhoff, S., De La Vega, A., Esteban, O., Etzel, J.A., Finc, K., Ganz, M., Gau, R.,
+Halchenko, Y.O. and Herholz, P., 2022. Open and reproducible neuroimaging: from study inception to publication.
+3. Tong, G., Gaspar, A.S., Qian, E., Ravi, K.S., Vaughan, J.T., Nunes, R.G. and Geethanath, S., 2022. Open-source
+magnetic resonance imaging acquisition: Data and documentation for two validated pulse sequences. Data in Brief, 42,
+p.108105.
+4. Tong, G., Gaspar, A.S., Qian, E., Ravi, K.S., Vaughan Jr, J.T., Nunes, R.G. and Geethanath, S., 2022. A framework
+for validating open-source pulse sequences. Magnetic resonance imaging, 87, pp.7-18.
+5. Karakuzu, A., Appelhoff, S., Auer, T., Boudreau, M., Feingold, F., Khan, A.R., Lazari, A., Markiewicz, C., Mulder,
+M.J., Phillips, C. and Salo, T., 2021. qMRI-BIDS: an extension to the brain imaging data structure for quantitative
+magnetic resonance imaging data. medRxiv.
+6. Karakuzu, A., Biswas, L., Cohen‐Adad, J. and Stikov, N., 2021. Vendor‐neutral sequences and fully transparent
+workflows improve inter‐vendor reproducibility of quantitative MRI. Magnetic Resonance in Medicine.
+7. Geethanath, S., Single echo reconstruction for rapid and silent MRI. (ISMRM) (2021).
+8. Qian, E. and Geethanath, S., Open source Magnetic rEsonance fingerprinting pAckage (OMEGA). (ISMRM) (2021).
+9. Ravi, K.S., O'Reilly, T., Vaughan Jr, J.T., Webb, A. and Geethanath, S., Seq2prospa: translating PyPulseq for
+low-field imaging. (ISMRM) (2021).
+10. Ravi, K.S., Vaughan Jr, J.T. and Geethanath, S., PyPulseq in a web browser: a zero footprint tool for collaborative
+and vendor-neutral pulse sequence development. (ISMRM) (2021).
+11. Ravi, K.S. and Geethanath, S., 2020. Autonomous magnetic resonance imaging. Magnetic Resonance Imaging, 73,
+pp.177-185.
+12. Nunes, Rita G., et al. "Implementation of a Diffusion-Weighted Echo Planar Imaging sequence using the Open Source
+Hardware-Independent PyPulseq Tool." ISMRM & SMRT Virtual Conference & Exhibition, International Society for Magnetic
+Resonance in Medicine (ISMRM) (2020).
+13. Loktyushin, Alexander, et al. "MRzero--Fully automated invention of MRI sequences using supervised learning." arXiv
+preprint arXiv:2002.04265 (2020).
+14. Jimeno, Marina Manso, et al. "Cross-vendor implementation of a Stack-of-spirals PRESTO BOLD fMRI sequence using
+TOPPE and Pulseq." ISMRM & SMRT Virtual Conference & Exhibition, International Society for Magnetic Resonance in
+Medicine (ISMRM) (2020).
+15. Clarke, William T., et al. "Multi-site harmonization of 7 tesla MRI neuroimaging protocols." NeuroImage 206 (2020): 116335.
+16. Geethanath, Sairam, and John Thomas Vaughan Jr. "Accessible magnetic resonance imaging: a review." Journal of
+Magnetic Resonance Imaging 49.7 (2019): e65-e77.
+17. Tong, Gehua, et al. "Virtual Scanner: MRI on a Browser." Journal of Open Source Software 4.43 (2019): 1637.
+18. Archipovas, Saulius, et al. "A prototype of a fully integrated environment for a collaborative work in MR sequence
+development for a reproducible research." ISMRM 27th Annual Meeting & Exhibition, International Society for
+Magnetic Resonance in Medicine (ISMRM) (2019).
+19. Pizetta, Daniel Cosmo. PyMR: a framework for programming magnetic resonance systems. Diss. Universidade de São
+Paulo (2018).
+---
+
+## 3. 🔨 Installation
+\>=Python 3.6, virtual environment recommended:
+
+```pip install pypulseq```
+
+## 4. ⚡ Lightning-start - PyPulseq in your browser!
+1. Create a new notebook on [Google Colab][google-colab]
+2. [Install][section-installation] PyPulseq
+3. Get going!
+
+Or, explore an example notebook:
+1. Copy URL of an example notebook from [here][section-notebook-examples]
+2. On [Google Colab][google-colab], insert the copied link to get started
+
+## 5. 🏃‍♂ Quickstart - example scripts
+Every example script creates a pulse sequence, plots the pulse timing diagram and writes a `.seq` file to disk.
+1. [Install][section-installation] PyPulseq
+2. Download and run any of the [example][script-examples] scripts.
+
+## 6. 🤿 Deep dive - custom pulse sequences
+Getting started with pulse sequence design using `PyPulseq` is simple:
+1. [Install][section-installation] PyPulseq
+2. First, define system limits in `Opts` and then create a `Sequence` object with it:
+    ```python
+    import pypulseq as pp
+
+    system = pp.Opts(max_grad=32, grad_unit='mT/m', max_slew=130, slew_unit='mT/m/ms')
+    seq = pp.Sequence(system=system)
+    ```
+3. Then, design gradient, RF or ADC pulse sequence events:
+    ```python
+    Nx, Ny = 256, 256 # matrix size
+    fov = 220e-3 # field of view
+    delta_k = fov / Nx
+
+    # RF sinc pulse with a 90 degree flip angle
+    rf90 = pp.make_sinc_pulse(flip_angle=90, duration=2e-3, system=system, slice_thickness=5e-3, apodization=0.5,
+       time_bw_product=4)
+
+    # Frequency encode, trapezoidal event
+    gx = pp.make_trapezoid(channel='x', flat_area=Nx * delta_k, flat_time=6.4e-3, system=system)
+
+    # ADC readout
+    adc = pp.make_adc(num_samples=Nx, duration=gx.flat_time, delay=gx.rise_time, system=system)
+    ```
+4. Add these pulse sequence events to the `Sequence` object from step 2. One or more events can be executed
+simultaneously, simply pass them all to the `add_block()` method. For example, the `gx` and `adc` pulse sequence events
+need to be executed simultaneously:
+    ```python
+    seq.add_block(rf90)
+    seq.add_block(gx, adc)
+    ```
+5. Visualize plots:
+    ```python
+    seq.plot()
+    ```
+6. Generate a `.seq` file to be executed on a real MR scanner:
+    ```python
+    seq.write('demo.seq')
+    ```
+
+**Get in touch regarding running the `.seq` files on your Siemens/[GE]/[Bruker] scanner.**
+
+## 7. 👥 Contributing and Community guidelines
+`PyPulseq` adheres to a code of conduct adapted from the [Contributor Covenant] code of conduct.
+Contributing guidelines can be found [here][contrib-guidelines].
+
+## 8. 📖 References
+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.
+2. Ravi, Keerthi Sravan, et al. "Pulseq-Graphical Programming Interface: Open source visual environment for prototyping
+pulse sequences and integrated magnetic resonance imaging algorithm development." Magnetic resonance imaging 52 (2018):
+9-15.
+3. Layton, Kelvin J., et al. "Pulseq: a rapid and hardware‐independent pulse sequence prototyping framework." Magnetic
+resonance in medicine 77.4 (2017): 1544-1552.
+
+[Bruker]: https://github.com/pulseq/bruker_interpreter
+[Contributor Covenant]: http://contributor-covenant.org
+[GE]: https://toppemri.github.io
+[Pulseq specification]: https://pulseq.github.io/specification.pdf
+[api-docs]: https://pypulseq.readthedocs.io/
+[contrib-guidelines]: https://github.com/imr-framework/pypulseq/blob/master/CONTRIBUTING.md
+[google-colab]: https://colab.research.google.com/
+[scholar-citations]: https://scholar.google.com/scholar?oi=bibs&hl=en&cites=16703093871665262997
+[script-examples]: https://github.com/imr-framework/pypulseq/tree/dev/pypulseq/seq_examples/scripts
+[section-contributors]: #1--contributors-alphabetical
+[section-contributing]: #7--contributing-and-community-guidelines
+[section-deep-dive]: #6--deep-dive---custom-pulse-sequences
+[section-installation]: #3--installation
+[section-lightning-start]: #4--lightning-start---pypulseq-in-your-browser
+[section-quickstart-examples]: #5--quickstart---example-scripts
+[section-references]: #8--references
+[section-relevant-literature]: #2--citations-reverse-chronological

+ 39 - 0
libs/lf-scanner/TSE_20231019_161845.json

@@ -0,0 +1,39 @@
+{
+    "G_amp_max": 1609372.8,
+    "G_slew_max": 5151696000.0,
+    "gamma": 42576000.0,
+    "grad_raster_time": 1e-05,
+    "rf_raster_time": 1e-06,
+    "t_BW_product_ex": 3.55,
+    "t_BW_product_ref": 3.55,
+    "t_ex": 0.00298,
+    "t_ref": 0.00333,
+    "rf_ringdown_time": [
+        2e-05
+    ],
+    "rf_dead_time": [
+        0.0001
+    ],
+    "adc_dead_time": [
+        1e-05
+    ],
+    "aapodization": 0.27,
+    "dG": 0.00025,
+    "sl_nb": 1.0,
+    "sl_thkn": 0.005,
+    "sl_gap": 100.0,
+    "FoV_f": 0.032,
+    "FoV_ph": 0.032,
+    "Nf": 16.0,
+    "Np": 16.0,
+    "BW_pixel": 500.0,
+    "TE": 0.02,
+    "N_TE": 2.0,
+    "ES": 0.01,
+    "TR": 0.5,
+    "FA": 90.0,
+    "conct": 1.0,
+    "ETL": 8.0,
+    "Average": 1,
+    "delay_TR": 0.41484
+}

File diff suppressed because it is too large
+ 762 - 0
libs/lf-scanner/TSE_pulse_sequence-Copy1.ipynb


File diff suppressed because it is too large
+ 762 - 0
libs/lf-scanner/TSE_pulse_sequence.ipynb


File diff suppressed because it is too large
+ 762 - 0
libs/lf-scanner/TSE_pulse_sequence_T1.ipynb


File diff suppressed because it is too large
+ 722 - 0
libs/lf-scanner/TSE_splited_gradients.ipynb


File diff suppressed because it is too large
+ 628 - 0
libs/lf-scanner/TSE_splited_gradients_RESTORE.ipynb


+ 0 - 0
libs/lf-scanner/__init__.py


+ 20 - 0
libs/lf-scanner/doc/Makefile

@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = source
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

+ 0 - 0
libs/lf-scanner/doc/__init__.py


+ 35 - 0
libs/lf-scanner/doc/make.bat

@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd

+ 2 - 0
libs/lf-scanner/doc/readthedocs_requirements.txt

@@ -0,0 +1,2 @@
+furo==2021.4.11b34
+jinja2<3.1.0

+ 0 - 0
libs/lf-scanner/doc/source/__init__.py


+ 66 - 0
libs/lf-scanner/doc/source/conf.py

@@ -0,0 +1,66 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+import sys
+sys.path.insert(0, os.path.abspath('../../'))
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'pypulseq'
+copyright = '2023, Keerthi Sravan Ravi'
+author = 'Keerthi Sravan Ravi'
+
+# The full version, including alpha/beta/rc tags
+release = '1.4.0'
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'recommonmark'
+]
+
+source_suffix = {
+    '.rst': 'restructuredtext',
+    '.txt': 'restructuredtext',
+    '.md': 'markdown',
+}
+
+source_parsers = {'.md': 'recommonmark.parser.CommonMarkParser'}
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = ['setup*', 'version*']
+
+autodoc_mock_imports = ['numpy', 'matplotlib', 'scipy', 'sigpy']
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'furo'
+html_logo = '../../logo_transparent.png'
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']

+ 25 - 0
libs/lf-scanner/doc/source/index.rst

@@ -0,0 +1,25 @@
+pypulseq
+====================================
+.. image:: ../../logo.png
+   :align: center
+
+Introduction
+============
+`pypulseq <https://github.com/imr-framework/pypulseq>`_ enables vendor-neutral pulse sequence design in Python [1]_ [2]_. The pulse sequences can be exported as a `.seq` file to be run on  Siemens/`GE <https://toppemri.github.io>`_/`Bruker <https://github.com/pulseq/bruker_interpreter>`_ hardware by leveraging their respective Pulseq interpreters. This tool is targeted at MR pulse sequence designers, MRI researchers and other interested users. It is a translation of the Pulseq framework originally written in Matlab [3]_.
+
+It is strongly recommended to first read the `Pulseq specification <https://pulseq.github.io/specification.pdf>`_ before proceeding. The specification document defines the concepts required for pulse sequence design using `pypulseq`.
+
+.. [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.
+
+.. [2] Ravi, Keerthi Sravan, et al. "Pulseq-Graphical Programming Interface: Open source visual environment for prototyping pulse sequences and integrated magnetic resonance imaging algorithm development." Magnetic resonance imaging 52 (2018): 9-15.
+
+.. [3] Layton, Kelvin J., et al. "Pulseq: a rapid and hardware‐independent pulse sequence prototyping framework." Magnetic resonance in medicine 77.4 (2017): 1544-1552.
+
+
+API documentation
+==================
+
+.. toctree::
+    :maxdepth: 2
+
+    modules

+ 9 - 0
libs/lf-scanner/doc/source/modules.rst

@@ -0,0 +1,9 @@
+pypulseq
+========
+
+.. toctree::
+   :maxdepth: 4
+
+   pypulseq
+   setup
+   version

+ 21 - 0
libs/lf-scanner/doc/source/pypulseq.SAR.rst

@@ -0,0 +1,21 @@
+pypulseq.SAR package
+====================
+
+Submodules
+----------
+
+pypulseq.SAR.SAR\_calc module
+-----------------------------
+
+.. automodule:: pypulseq.SAR.SAR_calc
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: pypulseq.SAR
+   :members:
+   :undoc-members:
+   :show-inheritance:

+ 61 - 0
libs/lf-scanner/doc/source/pypulseq.Sequence.rst

@@ -0,0 +1,61 @@
+pypulseq.Sequence package
+=========================
+
+Submodules
+----------
+
+pypulseq.Sequence.block module
+------------------------------
+
+.. automodule:: pypulseq.Sequence.block
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.Sequence.ext\_test\_report module
+------------------------------------------
+
+.. automodule:: pypulseq.Sequence.ext_test_report
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.Sequence.parula module
+-------------------------------
+
+.. automodule:: pypulseq.Sequence.parula
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.Sequence.read\_seq module
+----------------------------------
+
+.. automodule:: pypulseq.Sequence.read_seq
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.Sequence.sequence module
+---------------------------------
+
+.. automodule:: pypulseq.Sequence.sequence
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.Sequence.write\_seq module
+-----------------------------------
+
+.. automodule:: pypulseq.Sequence.write_seq
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: pypulseq.Sequence
+   :members:
+   :undoc-members:
+   :show-inheritance:

+ 319 - 0
libs/lf-scanner/doc/source/pypulseq.rst

@@ -0,0 +1,319 @@
+pypulseq package
+================
+
+Subpackages
+-----------
+
+.. toctree::
+   :maxdepth: 4
+
+   pypulseq.SAR
+   pypulseq.Sequence
+   pypulseq.tests
+
+Submodules
+----------
+
+pypulseq.add\_gradients module
+------------------------------
+
+.. automodule:: pypulseq.add_gradients
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.add\_ramps module
+--------------------------
+
+.. automodule:: pypulseq.add_ramps
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.align module
+---------------------
+
+.. automodule:: pypulseq.align
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.block\_to\_events module
+---------------------------------
+
+.. automodule:: pypulseq.block_to_events
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.calc\_duration module
+------------------------------
+
+.. automodule:: pypulseq.calc_duration
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.calc\_ramp module
+--------------------------
+
+.. automodule:: pypulseq.calc_ramp
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.calc\_rf\_bandwidth module
+-----------------------------------
+
+.. automodule:: pypulseq.calc_rf_bandwidth
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.calc\_rf\_center module
+--------------------------------
+
+.. automodule:: pypulseq.calc_rf_center
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.check\_timing module
+-----------------------------
+
+.. automodule:: pypulseq.check_timing
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.compress\_shape module
+-------------------------------
+
+.. automodule:: pypulseq.compress_shape
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.convert module
+-----------------------
+
+.. automodule:: pypulseq.convert
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.decompress\_shape module
+---------------------------------
+
+.. automodule:: pypulseq.decompress_shape
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.event\_lib module
+--------------------------
+
+.. automodule:: pypulseq.event_lib
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_adc module
+-------------------------
+
+.. automodule:: pypulseq.make_adc
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_adiabatic\_pulse module
+--------------------------------------
+
+.. automodule:: pypulseq.make_adiabatic_pulse
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_arbitrary\_grad module
+-------------------------------------
+
+.. automodule:: pypulseq.make_arbitrary_grad
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_arbitrary\_rf module
+-----------------------------------
+
+.. automodule:: pypulseq.make_arbitrary_rf
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_block\_pulse module
+----------------------------------
+
+.. automodule:: pypulseq.make_block_pulse
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_delay module
+---------------------------
+
+.. automodule:: pypulseq.make_delay
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_digital\_output\_pulse module
+--------------------------------------------
+
+.. automodule:: pypulseq.make_digital_output_pulse
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_extended\_trapezoid module
+-----------------------------------------
+
+.. automodule:: pypulseq.make_extended_trapezoid
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_extended\_trapezoid\_area module
+-----------------------------------------------
+
+.. automodule:: pypulseq.make_extended_trapezoid_area
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_gauss\_pulse module
+----------------------------------
+
+.. automodule:: pypulseq.make_gauss_pulse
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_label module
+---------------------------
+
+.. automodule:: pypulseq.make_label
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_sigpy\_pulse module
+----------------------------------
+
+.. automodule:: pypulseq.make_sigpy_pulse
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_sinc\_pulse module
+---------------------------------
+
+.. automodule:: pypulseq.make_sinc_pulse
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_trapezoid module
+-------------------------------
+
+.. automodule:: pypulseq.make_trapezoid
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.make\_trigger module
+-----------------------------
+
+.. automodule:: pypulseq.make_trigger
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.opts module
+--------------------
+
+.. automodule:: pypulseq.opts
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.points\_to\_waveform module
+------------------------------------
+
+.. automodule:: pypulseq.points_to_waveform
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.rotate module
+----------------------
+
+.. automodule:: pypulseq.rotate
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.scale\_grad module
+---------------------------
+
+.. automodule:: pypulseq.scale_grad
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.sigpy\_pulse\_opts module
+----------------------------------
+
+.. automodule:: pypulseq.sigpy_pulse_opts
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.split\_gradient module
+-------------------------------
+
+.. automodule:: pypulseq.split_gradient
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.split\_gradient\_at module
+-----------------------------------
+
+.. automodule:: pypulseq.split_gradient_at
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.supported\_labels\_rf\_use module
+------------------------------------------
+
+.. automodule:: pypulseq.supported_labels_rf_use
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.traj\_to\_grad module
+------------------------------
+
+.. automodule:: pypulseq.traj_to_grad
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: pypulseq
+   :members:
+   :undoc-members:
+   :show-inheritance:

+ 117 - 0
libs/lf-scanner/doc/source/pypulseq.tests.rst

@@ -0,0 +1,117 @@
+pypulseq.tests package
+======================
+
+Submodules
+----------
+
+pypulseq.tests.base module
+--------------------------
+
+.. automodule:: pypulseq.tests.base
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_MPRAGE module
+----------------------------------
+
+.. automodule:: pypulseq.tests.test_MPRAGE
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_epi module
+-------------------------------
+
+.. automodule:: pypulseq.tests.test_epi
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_epi\_label module
+--------------------------------------
+
+.. automodule:: pypulseq.tests.test_epi_label
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_epi\_se module
+-----------------------------------
+
+.. automodule:: pypulseq.tests.test_epi_se
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_epi\_se\_rs module
+---------------------------------------
+
+.. automodule:: pypulseq.tests.test_epi_se_rs
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_gre module
+-------------------------------
+
+.. automodule:: pypulseq.tests.test_gre
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_gre\_label module
+--------------------------------------
+
+.. automodule:: pypulseq.tests.test_gre_label
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_gre\_radial module
+---------------------------------------
+
+.. automodule:: pypulseq.tests.test_gre_radial
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_haste module
+---------------------------------
+
+.. automodule:: pypulseq.tests.test_haste
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_sigpy module
+---------------------------------
+
+.. automodule:: pypulseq.tests.test_sigpy
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_tse module
+-------------------------------
+
+.. automodule:: pypulseq.tests.test_tse
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pypulseq.tests.test\_ute module
+-------------------------------
+
+.. automodule:: pypulseq.tests.test_ute
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: pypulseq.tests
+   :members:
+   :undoc-members:
+   :show-inheritance:

+ 7 - 0
libs/lf-scanner/doc/source/setup.rst

@@ -0,0 +1,7 @@
+setup module
+============
+
+.. automodule:: setup
+   :members:
+   :undoc-members:
+   :show-inheritance:

+ 7 - 0
libs/lf-scanner/doc/source/version.rst

@@ -0,0 +1,7 @@
+version module
+==============
+
+.. automodule:: version
+   :members:
+   :undoc-members:
+   :show-inheritance:

BIN
libs/lf-scanner/doc/walkthrough/gre_1.png


BIN
libs/lf-scanner/doc/walkthrough/gre_2.png


File diff suppressed because it is too large
+ 84 - 0
libs/lf-scanner/doc/walkthrough/gre_walkthrough.ipynb


BIN
libs/lf-scanner/logo.png


BIN
libs/lf-scanner/logo_transparent.png


+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/__init__.py


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/pd_TSE_matrx16x16_fixed_delay.xml


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/pd_TSE_matrx16x16_myGrad.xml


BIN
libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/rf_1.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/rf_2.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/pd_TSE/rf_3.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_FS_TSE/FS_T1_TSE_1.png


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_FS_TSE/FS_T1_TSE_2.png


+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/__init__.py


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/rf_1.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/rf_2.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/rf_3.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/rf_4.h5


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/t1_TSE_matrx16x16_fixed_delay.xml


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/t1_TSE_matrx16x16_myGrad.xml


+ 432 - 0
libs/lf-scanner/new_MRI_pulse_seq/t1_TSE/write_TSE_T1.py

@@ -0,0 +1,432 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_gauss_pulse import make_gauss_pulse
+from pypulseq.make_adiabatic_pulse import make_adiabatic_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+def FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    params['FS_sat_ppm'] = -3.45  # TODO add to GUI
+    params['FS_pulse_duration'] = 8e-3  # TODO add to GUI
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+
+    rf_fs = make_gauss_pulse(flip_angle=flip_fs, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(FS_sat_frequency), freq_offset=FS_sat_frequency)
+    gx_fs = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= g_rf_area, rise_time=params['dG'])
+    gy_fs = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= g_rf_area, rise_time=params['dG'])
+
+    return rf_fs, gx_fs, gy_fs
+
+def inversion_block(params, scanner_parameters):
+    #function creates inversion recovery block with delay
+    params['IR_time'] = 0.140  # STIR # TODO add to GUI
+    #params['IR_time'] = 2.250  # FLAIR # TODO add to GUI
+    flip_ir = round(180 * pi / 180)
+    rf_ir, gz_ir, _ = make_sinc_pulse(flip_angle=flip_ir, system=scanner_parameters, duration=params['t_ref'],
+                                      slice_thickness=params['sl_thkn'], apodization=0.3,
+                                      time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                      return_gz=True)
+    delay_IR = np.ceil(params['IR_time'] / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    delay_IR = make_delay(delay_IR)
+
+    return rf_ir, gz_ir, delay_IR
+
+def SPAIR_block(params, scanner_parameters, g_rf_area):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    params['FS_sat_ppm'] = -3.45  # TODO add to GUI
+    params['FS_pulse_duration'] = 8e-3  # TODO add to GUI
+    params['IR_time'] = 0.140  # SPAIR # TODO add to GUI
+
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+    flip_SPAIR = round(180 * pi / 180)
+    rf_SPAIR = make_adiabatic_pulse(pulse_type ="hypsec", system=scanner_parameters,
+                                    freq_offset=FS_sat_frequency)
+    gx_SPAIR = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_SPAIR),
+                           area= g_rf_area, rise_time=params['dG'])
+    gy_SPAIR = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_SPAIR),
+                           area= g_rf_area, rise_time=params['dG'])
+
+    delay_IR = np.ceil(params['IR_time'] / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    delay_IR = make_delay(delay_IR)
+
+    return rf_SPAIR, gx_SPAIR, gy_SPAIR, delay_IR
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number, order):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append(int((ETL - 1) * n_ex - i * n_ex))
+    # print(k_space_list_with_zero)
+    central_num = int(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    if order == 'non_linear':
+        a = k_space_list_with_zero[:((shift-index_central_line)*2+1)]
+        b = k_space_list_with_zero[((shift-index_central_line)*2+1):]
+        for i in range(1, int(len(b)/2)+1):
+            a.append(b[i-1])
+            a.append(b[-i])
+        a.append(b[i])
+        k_space_list_with_zero = a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+
+    return k_space_order_filing
+
+
+def main(plot: bool, write_seq: bool, weightning, FS: bool, IR: bool, SPAIR):
+
+    # Reading json file according to the weightning of the image
+    if weightning == 'T1': #TODO: create general path
+        if FS:
+            with open('C:\MRI_seq_files_mess\TSE\FS_TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+        elif IR:
+            with open('C:\MRI_seq_files_mess\TSE\IR_TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE\TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+
+    elif weightning == 'T2':
+        if FS:
+            with open('C:\MRI_seq_files_mess\TSE\FS_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        elif IR:
+            with open('C:\MRI_seq_files_mess\TSE\IR_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        elif SPAIR:
+            with open('C:\MRI_seq_files_mess\TSE\SPAIR_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE\TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+
+    elif weightning == 'PD':
+        if FS:
+            with open('C:\MRI_seq_files_mess\TSE\FS_TSE_PD.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE\TSE_PD.json', 'rb') as f:
+                params = j.load(f)
+    else:
+        print('Please choose image weightning')
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    flip_fs = round(110 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    if FS: #TODO add to GUI choise of including or not Fat Sat block
+        g_rf_area = gz_ex.area * 10
+        rf_fs, gx_fs, gy_fs = FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs)
+
+    if IR: #TODO add to GUI choise of including or not Inversion block
+        rf_ir, gz_ir, delay_IR = inversion_block(params, scanner_parameters)
+
+    if SPAIR: #TODO add to GUI choise of including or not Inversion block
+        g_rf_area = gz_ex.area * 10
+        rf_SPAIR, gx_SPAIR, gy_SPAIR, delay_IR = SPAIR_block(params, scanner_parameters, g_rf_area)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']), 'non_linear')  # TODO: to create additiolal functions on different k space order filling
+    k_space_save = {'k_space_order': k_space_order_filing}
+
+    output_filename = "k_space_order_filing"  #save for reconstruction outside the jemris
+    # output_filename = "TSE_T1" + datetime.now().strftime("%Y%m%d_%H%M%S")
+    with open(output_filename + ".json", 'w') as outfile:
+        j.dump(k_space_save, outfile)
+
+
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL']) - 1):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+    block_duration += calc_duration(gz_cr)
+    if FS:
+        block_duration += calc_duration(gx_fs)
+    if IR:
+        block_duration += max(calc_duration(rf_ir), calc_duration(gz_ir))
+        block_duration += calc_duration(delay_IR)
+    if SPAIR:
+        block_duration += calc_duration(gx_SPAIR)
+        block_duration += calc_duration(delay_IR)
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+                    if FS:
+                        seq.add_block(gx_fs, gy_fs, rf_fs)
+                    if IR:
+                        seq.add_block(gz_ir, rf_ir)
+                        seq.add_block(delay_IR)
+                    if SPAIR:
+                        seq.add_block(gx_SPAIR, gy_SPAIR, rf_SPAIR)
+                        seq.add_block(delay_IR)
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_cr, gx_spoil, gy_pre)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq: #TODO: create general path
+        if FS:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_FS_TSE\\FS_t1_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_FS_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_FS_TSE\\FS_t2_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_FS_TSE')
+
+            elif weightning == 'PD':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_FS_TSE\\FS_pd_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_FS_TSE')
+
+            else:
+                print('Please choose image weightning')
+        elif IR:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_IR_TSE\\IR_t1_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='IR_t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_IR_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_IR_TSE\\IR_t2_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='IR_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_IR_TSE')
+            else:
+                print('Please choose image weightning')
+        elif SPAIR:
+            if weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_SPAIR_TSE\\SPAIR_t2_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='SPAIR_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_SPAIR_TSE')
+
+        else:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE\\t1_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE\\t2_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE')
+
+            elif weightning == 'PD':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE\\pd_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE')
+            else:
+                print('Please choose image weightning')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, weightning='T1', FS=False, IR=False, SPAIR=False)

BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_se/rf_1.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_se/rf_2.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_se/rf_3.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_se/rf_4.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t1_se/with_gxspoi_without_phases_offsets/different_contrasts.pptx


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/IR_t2_TSE_matrx16x16_myGrad.xml


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/T2_STIR_TSE_1.png


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/T2_STIR_TSE_2.png


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/rf_1.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/rf_2.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_IR_TSE/rf_3.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/rf_1.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/rf_2.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/rf_3.h5


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/t2_TSE_matrx16x16_fixed_delay.xml


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE/t2_TSE_matrx16x16_myGrad.xml


+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/__init__.py


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/rf_1.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/rf_2.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/rf_3.h5


BIN
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/rf_4.h5


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/t2_TSE_RESTORE_matrx16x16_myGrad.xml


+ 289 - 0
libs/lf-scanner/new_MRI_pulse_seq/t2_TSE_RESTORE/write_TSE_T2_RESTORE.py

@@ -0,0 +1,289 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append(int((ETL - 1) * n_ex - i * n_ex))
+    # print(k_space_list_with_zero)
+    central_num = int(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+    return k_space_order_filing
+
+
+def main(plot: bool, write_seq: bool, weightning):
+
+    # Reading json file according to the weightning of the image
+    if weightning == 'T2':
+        with open('C:\MRI_seq_files_mess\TSE\RESTORE_T2.json', 'rb') as f:
+            params = j.load(f)
+    else:
+        print('exists only for T2')
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    # Restore RF pulse -90
+    rf_restore, gz_resto, _ = make_sinc_pulse(flip_angle=-flip90, system=scanner_parameters, duration=params['t_ex'],
+                                              slice_thickness=params['sl_thkn'], apodization=0.3,
+                                              time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+    pulse_offsets_restore = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                        params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_resto.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_restore = t_exwd
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+    gz_restore = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_resto.amplitude,
+                                flat_time=t_restore, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']))  # TODO: to create additiolal functions on different k space order filling
+    k_space_save = {'k_space_order': k_space_order_filing}
+    output_filename = "k_space_order_filing"  # save for reconstruction outside the jemris
+    # output_filename = "TSE_T1" + datetime.now().strftime("%Y%m%d_%H%M%S")
+    with open(output_filename + ".json", 'w') as outfile:
+        j.dump(k_space_save, outfile)
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL'])):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz180))
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(rf90), calc_duration(gz90))
+    block_duration += calc_duration(gz_cr)
+
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    rf_restore.freq_offset = pulse_offsets_restore[curr_slice]
+                    rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                            seq.add_block(gz180, rf180)
+                            seq.add_block(gz_reph, gx_pre)
+                            seq.add_block(gz_restore, rf_restore)
+                            seq.add_block(gz_cr)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if weightning == 'T2':
+        seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE_RESTORE\\t2_TSE_RESTORE_matrix32x32.seq')  # Save to disk
+        seq2xml(seq, seq_name='t2_TSE_RESTORE_matrx32x32',
+                out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE_RESTORE')
+
+    else:
+        print('works only with T2')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, weightning='T2')

+ 106 - 0
libs/lf-scanner/paper.bib

@@ -0,0 +1,106 @@
+@article{layton2017pulseq,
+  title={Pulseq: a rapid and hardware-independent pulse sequence prototyping framework},
+  doi={10.1002/mrm.26235},
+  author={Layton, Kelvin J and Kroboth, Stefan and Jia, Feng and Littin, Sebastian and Yu, Huijun and Leupold, Jochen and Nielsen, Jon-Fredrik and St{\"o}cker, Tony and Zaitsev, Maxim},
+  journal={Magnetic resonance in medicine},
+  volume={77},
+  number={4},
+  pages={1544--1552},
+  year={2017},
+  publisher={Wiley Online Library}
+}
+
+@inproceedings{ravi2018amri,
+  address = {Washington, D.C.},
+  author = {Ravi, Keerthi Sravan and Geethanath, Sairam and Weber Jochen and Vaughan, John Thomas},
+  booktitle = {ISMRM Workshop on Machine Learning Part II,},
+  title = {MR Value driven Autonomous MRI using imr-framework},
+  year = {2018}
+}
+
+@inproceedings{ravi2018imrframework,
+  author = {Ravi, Keerthi Sravan and Geethanath, Sairam and Vaughan, John Thomas},
+  booktitle = {i2i Workshop},
+  title = {imr-framework for rapid design and deployment of non-Cartesian sequences},
+  url = {cai2r.net/i2i},
+  year = {2018}
+}
+
+@article{ravi2018pulseq-gpi,
+  title={Pulseq-Graphical Programming Interface: Open source visual environment for prototyping pulse sequences and integrated magnetic resonance imaging algorithm development},
+  doi={10.1016/j.mri.2018.03.008},
+  author={Ravi, Keerthi Sravan and Potdar, Sneha and Poojar, Pavan and Reddy, Ashok Kumar and Kroboth, Stefan and Nielsen, Jon-Fredrik and Zaitsev, Maxim and Venkatesan, Ramesh and Geethanath, Sairam},
+  journal={Magnetic resonance imaging},
+  volume={52},
+  pages={9--15},
+  year={2018},
+  publisher={Elsevier}
+}
+
+@inproceedings{gehua2019ismrm,
+  address = {Montreal, Canada},
+  author = {Tong, Gehua and Geethanath, Sairam and Qian, Enlin and Ravi, Keerthi Sravan and Jimeno Manso, Marina and Vaughan, John Thomas},
+  booktitle = {ISMRM 27th Annual Meeting and Exhibition},
+  title = {Virtual MR Scanner Software},
+  year = {2019}
+}
+
+@article{jochimsen2004odin,
+  title={ODIN—object-oriented development interface for NMR},
+  doi={10.1016/j.jmr.2004.05.021},
+  author={Jochimsen, Thies H and Von Mengershausen, Michael},
+  journal={Journal of Magnetic Resonance},
+  volume={170},
+  number={1},
+  pages={67--78},
+  year={2004},
+  publisher={Elsevier}
+}
+
+@article{magland2016pulse,
+  title={Pulse sequence programming in a dynamic visual environment: SequenceTree},
+  doi={10.1002/mrm.25640},
+  author={Magland, Jeremy F and Li, Cheng and Langham, Michael C and Wehrli, Felix W},
+  journal={Magnetic resonance in medicine},
+  volume={75},
+  number={1},
+  pages={257--265},
+  year={2016},
+  publisher={Wiley Online Library}
+}
+
+@article{nielsen2018toppe,
+  title={TOPPE: A framework for rapid prototyping of MR pulse sequences},
+  doi={10.1002/mrm.26990},
+  author={Nielsen, Jon-Fredrik and Noll, Douglas C},
+  journal={Magnetic resonance in medicine},
+  volume={79},
+  number={6},
+  pages={3128--3134},
+  year={2018},
+  publisher={Wiley Online Library}
+}
+
+@article{poojar2019rapid,
+  title={Rapid prOtotyping of 2D non-CartesIan K-space trajEcTories (ROCKET) using Pulseq and GPI},
+  doi={10.1615/CritRevBiomedEng.2019029380},
+  author={Poojar, Pavan and Geethanath, Sairam and Reddy, Ashok Kumar and Venkatesan, Ramesh},
+  journal={Critical Reviews™ in Biomedical Engineering},
+  publisher={Begel House Inc.}
+}
+
+@inproceedings{ravi2019accessible-amri,
+  address = {New Delhi, India},
+  author = {Ravi, Keerthi Sravan and Geethanath, Sairam and Vaughan, John Thomas},
+  booktitle = {ISMRM Workshop on Accessible MRI for the World},
+  title = {Autonomous scanning using imr-framework to improve MR accessibility},
+  year = {2019}
+}
+
+@inproceedings{ravi2019selfadmin,
+  address = {Montreal, Canada},
+  author = {Ravi, Keerthi Sravan and Geethanath, Sairam and Vaughan, John Thomas},
+  booktitle = {ISMRM 27th Annual Meeting and Exhibition},
+  title = {Self-administered exam using Autonomous Magnetic Resonance Imaging (AMRI)},
+  year = {2019}
+}

+ 106 - 0
libs/lf-scanner/paper.md

@@ -0,0 +1,106 @@
+---
+title: 'PyPulseq: A Python Package for MRI Pulse Sequence Design'
+tags:
+  - Python
+  - MRI
+  - pulse sequence design
+  - vendor neutral
+authors:
+  - name: Keerthi Sravan Ravi
+    orcid: 0000-0001-6886-0101
+    affiliation: 1
+  - name: Sairam Geethanath
+    orcid: 0000-0002-3776-4114
+    affiliation: 1
+  - name: John Thomas Vaughan Jr.
+    orcid: 0000-0002-6933-3757
+    affiliation: 1  
+affiliations:
+ - name: Columbia Magnetic Resonance Research Center, Columbia University in the City of New York, USA
+   index: 1
+date: 21 August 2019
+bibliography: paper.bib
+---
+
+# Summary
+
+Magnetic Resonance Imaging (MRI) is a critical component of healthcare. MRI data is acquired by playing a series of 
+radio-frequency and magnetic field gradient pulses. Designing these pulse sequences requires knowledge of specific 
+programming environments depending on the vendor hardware (generations) and software (revisions) intended for 
+implementation. This impedes the pace of prototyping. Pulseq [@layton2017pulseq] introduced an open source file 
+standard for pulse sequences that can be deployed on Siemens/GE via [TOPPE](https://toppemri.github.io) 
+[@nielsen2018toppe]/[Bruker](https://github.com/pulseq/bruker_interpreter) platforms. In this work, we introduce 
+`PyPulseq`, which enables pulse sequence programming in Python. Its advantages are zero licensing fees and easy 
+integration with deep learning methods developed in Python. `PyPulseq` is aimed at MRI researchers, faculty, students, 
+and other allied field researchers such as those in neuroscience. We have leveraged this tool for several published 
+research works [@poojar2019rapid; @gehua2019ismrm; @ravi2018amri; @ravi2019accessible-amri; @ravi2018imrframework; 
+@ravi2019selfadmin].
+
+# Statement of need
+
+MRI is a non-invasive diagnostic imaging tool. It is a critical component of healthcare and has a significant impact on 
+diagnosis and treatment assessment. Structural, functional and metabolic MRI generate valuable information that aid in 
+the accurate diagnosis of a wide range of pathologies. A unique strength of MRI is the ability to visualise diverse 
+pathologies achieved by the flexibility in designing tailored pulse sequences. MRI pulse sequences are a collection of 
+radio-frequency and gradient waveforms that are executed on the scanner hardware to acquire raw data. 
+
+Research efforts on pulse sequence design are directed at achieving faster scan times, improving tissue contrast and 
+increasing Signal-to-Noise Ratio (SNR). However, designing pulse sequences requires knowledge of specific programming 
+environments depending on the vendor hardware (generations) and software (revisions) intended for implementation. 
+Typically, MRI researchers program and simulate the pulse sequences on computers and execute them on MRI scanners. This 
+typically involves considerable effort, impeding the pace of prototyping and therefore research and development. This 
+also hampers multi-site multi-vendor studies as it requires researchers to be acquainted with each vendor's programming 
+environment. Furthermore, harmonizing acquisition across MRI vendors will enable reproducible research. This work 
+introduces an open source tool that enables pulse sequence programming for Siemens/GE/Bruker platforms in Python, based 
+on the Pulseq standard [@layton2017pulseq].
+
+# Introduction to the Pulseq file format: `.seq`
+
+The `.seq` file format introduced in Pulseq [@layton2017pulseq] is a novel way to capture a pulse sequence as plain 
+text. The file format was designed with the following design criteria in mind: human-readable, easily parsable, vendor 
+independent, compact and low-level [@layton2017pulseq]. A pulse sequence comprises of radiofrequency pulses, magnetic 
+field gradient waveforms, delays or analog-to-digital converter (ADC) readout *events*. A *block* comprises of one or 
+more *events* occurring simultaneously. *Event* envelopes are defined by *shapes*, which are run-length encoded and 
+stored in the `.seq` file. In a `.seq` file, each *event* and *shape* is identified uniquely by an integer. *Blocks* 
+are constructed by assembling the uniquely referenced *events*. Therefore, any custom pulse sequence can be synthesised 
+by concatenating *blocks*.
+
+# About `PyPulseq`
+
+The `PyPulseq` package presented in this work is an open source vendor-neutral MRI pulse sequence design tool. It 
+enables researchers and users to program pulse sequences in Python, and export them as a `.seq` file. These `.seq` files 
+can be executed on the three MRI vendors by leveraging vendor-specific interpreters. The MRI methods have been reported 
+previously [@ravi2018pulseq-gpi]. The `PyPulseq` package allows for both representing and deploying custom sequences. 
+This work focuses on the software aspect of the tool. `PyPulseq` was entirely developed in Python, and this has multiple 
+advantages. Firstly, unlike existing C++ frameworks such as ODIN [@jochimsen2004odin] and SequenceTree [@magland2016pulse],
+`PyPulseq` does not require any compilation of the pulse sequence scripts. Secondly, it does not involve any licensing 
+fees that are otherwise associated with other scientific research platforms such as MATLAB. Thirdly, there has been a 
+proliferation of deep learning projects developed in Python in recent years. These advantages allow `PyPulseq` to be 
+integrated with projects related to various stages of the MRI pipeline. For example - deep learning techniques for 
+acquisition (intelligent slice planning in @ravi2018amri) and related downstream reconstruction. Finally, the 
+standard Python package manager - PyPI - enables convenient installs on multiple OS platforms. These Python-derived 
+benefits ensure that `PyPulseq` can reach a wider audience.
+
+We have leveraged the `PyPulseq` library to implement acquisition oriented components of the Autonomous MRI (AMRI) 
+package [@ravi2018amri; @ravi2019accessible-amri; @ravi2019selfadmin], Virtual Scanner [@gehua2019ismrm], and the 
+non-Cartesian acquisition library [@ravi2018imrframework]. Also, the [`PyPulseq-gpi`](https://github.com/imr-framework/pypulseq/tree/pypulseq-gpi) branch 
+integrates a previous version of `PyPulseq` with [GPI](http://gpilab.com/) to enable GUI-based pulse sequence design. This work has 
+been previously reported [@ravi2018pulseq-gpi] and is not within the scope of this JOSS submission. Currently, 
+`PyPulseq` does not support external triggers and interactive slice planning. Raw data acquired with pulse sequences 
+designed with `PyPulseq` cannot be reconstructed vendor-supplied tools. `PyPulseq` is a translation of Pulseq from 
+MATLAB [@layton2017pulseq].
+
+# Target audience
+
+`PyPulseq` is aimed at MRI researchers focusing on pulse sequence design, image reconstruction, and MRI physics. We also 
+envisage PyPulseq to be utilized for replicability and reproducibility studies such as those for functional MRI 
+(multi-site, multi-vendor). The package could also serve as a hands-on teaching aid for MRI faculty and students. 
+Beginners can get started with the bundled example pulse sequences. More familiar users can import the appropriate 
+packages to construct and deploy custom pulse sequences.
+
+# Acknowledgements
+
+This study was funded (in part) by the 'MR Technology Development Grant' and the 'Seed Grant Program for MR Studies' 
+of the Zuckerman Mind Brain Behavior Institute at Columbia University (PI: Geethanath).
+
+# References

+ 31 - 0
libs/lf-scanner/py2jemris/.github/ISSUE_TEMPLATE/bug_report.md

@@ -0,0 +1,31 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Python Version [e.g. 3.8.1]
+
+**Additional context**
+Add any other context about the problem here.

+ 20 - 0
libs/lf-scanner/py2jemris/.github/ISSUE_TEMPLATE/feature_request.md

@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.

+ 120 - 0
libs/lf-scanner/py2jemris/.gitignore

@@ -0,0 +1,120 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+
+# database
+.db
+
+# Work in progress code
+src_working/
+
+# PyCharm idea files
+\.idea/
+/.idea/
+
+serverlog\.txt
+
+src/server/registration/subject\.db
+
+\.DS_Store

+ 76 - 0
libs/lf-scanner/py2jemris/CODE_OF_CONDUCT.md

@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity and expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at imr-framework2018@gmail.com. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq

+ 27 - 0
libs/lf-scanner/py2jemris/CONTRIBUTING.md

@@ -0,0 +1,27 @@
+# Contributing to `py2jemris`
+:thumbsup: :tada: Thanks for taking time to contribute! :thumbsup: :tada:
+
+Here are guidelines (not rules!) for contributing to `py2jemris`. Use your best judgment, and feel free to propose changes to this document in a pull request.
+
+## Table of contents
+1. [Code of Conduct](#code-of-conduct)
+2. [PEP Style Guide for Python coding](#style-guide-for-python-code)
+
+## Code of Conduct
+This project and everyone participating in it is governed by the 
+[`py2jemris` Code of Conduct][code_of_conduct]. 
+By participating, you are expected to uphold this code. Please report unacceptable behavior to 
+[imr.framework2018@gmail.com](email).
+
+## Pull requests
+Follow the coding conventions laid out in the [Style Guide for Python Code](style_guide). Ensure source code is 
+documented as per the Numpy convention [[numpy1]], [[numpy2]]. If you notice any `pypulseq` code not adhering to [PEP8](style-guide), submit a pull request or open an issue.
+
+## Issues
+Please adhere to the appropriate templates when reporting bugs or requesting features. The templates are automatically presented via Github's 'New Issue' feature.
+
+[email]: mailto:imr.framework2018@gmail.com
+[code_of_conduct]: https://github.com/imr-framework/py2jemris/blob/master/CODE_OF_CONDUCT.md
+[style_guide]: https://www.python.org/dev/peps/pep-0008/
+[numpy1]: https://numpydoc.readthedocs.io/en/latest/format.html
+[numpy2]: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html

+ 674 - 0
libs/lf-scanner/py2jemris/LICENSE

@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.

+ 27 - 0
libs/lf-scanner/py2jemris/README.md

@@ -0,0 +1,27 @@
+# py2jemris
+Python library for interfacing with the [JEMRIS][JEMRIS_repo] MR simulator.
+* Convert [Pulseq][Pulseq_repo]/[PyPulseq][PyPulseq_repo] sequence files (.seq) into JEMRIS format for simulation
+* Construct custom Tx/Rx coil maps and numerical phantoms
+* Perform JEMRIS simulation pipeline for rapid .seq file testing 
+
+## Introduction
+The JEMRIS project provides a fast and robust Bloch simulation core for Magentic Resonance Imaging (MRI) experiments, along with sequence design functions. The sequence representation in JEMRIS is high level, consisting of nested loops and parameter dependencies across sequence components [[1]](#references). In contrast, the Pulseq MR sequence standard represents the sequence in unrolled, consecutive blocks, with no interdependencies between blocks [[2]](@references). 
+
+Importantly, Pulseq is mainly intended for sequence development and can be interfaced to three main vendors for open-source acquisition. While JEMRIS can convert its sequences (typically, an .xml sequence construction file with a list of .h5 waveform data files) into the Pulseq format, it does not allow the reverse operation - converting any Pulseq sequence into a form ready for JEMRIS simulation. We developed py2jemris in order to incorporate simulations into our fully open-source sequence development cycle, as PyPulseq [[3]](#references) scripting allows flexible and rapid open-source sequence construction. 
+
+## Usage
+py2jemris is intended for rapid MR sequence development - it enables dual simulation/acquisition using the same sequence file. 
+
+## Get Started
+To get started, clone the repository and read the function docstrings. You will need to have JEMRIS installed on your system. A Wiki page and a Google Colab Notebook will be available soon.  
+
+## References 
+1. Stöcker, T., Vahedipour, K., Pflugfelder, D., & Shah, N. J. (2010). High‐performance computing MRI simulations. Magnetic resonance in medicine, 64(1), 186-193.
+2. Layton, K. J., Kroboth, S., Jia, F., Littin, S., Yu, H., Leupold, J., ... & Zaitsev, M. (2017). Pulseq: a rapid and hardware‐independent pulse sequence prototyping framework. Magnetic resonance in medicine, 77(4), 1544-1552.
+3. Ravi, K. S., Geethanath, S., & Vaughan, J. T. (2019). PyPulseq: A Python Package for MRI Pulse Sequence Design. Journal of Open Source Software, 4(42), 1725.
+
+
+
+[Pulseq_repo]: https://github.com/pulseq/pulseq
+[PyPulseq_repo]: https://github.com/imr-framework/pypulseq
+[JEMRIS_repo]: https://github.com/JEMRIS/jemris

+ 0 - 0
libs/lf-scanner/py2jemris/__init__.py


+ 1 - 0
libs/lf-scanner/py2jemris/benchmark_seq2xml/.jemris_progress.out

@@ -0,0 +1 @@
+0

BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/.spins_state.dat


+ 0 - 0
libs/lf-scanner/py2jemris/benchmark_seq2xml/__init__.py


+ 64 - 0
libs/lf-scanner/py2jemris/benchmark_seq2xml/benchmark_seq_files.py

@@ -0,0 +1,64 @@
+from pypulseq.Sequence.sequence import Sequence
+import matplotlib.pyplot as plt
+import h5py
+
+# Load both files
+
+
+q1 = 3
+q2 = 6
+
+seq_orig = Sequence()
+seq_orig.read('gre_pypulseq.seq')
+print('Seq original')
+print(seq_orig.get_block(q1).gx)
+
+
+
+seq_proc = Sequence()
+seq_proc.read('gre_jemris_seq2xml_jemris.seq')
+
+#print("Seq processed:")
+#print(seq_proc.get_block(q2).gx)
+
+#seq_orig.plot(time_range=[0,10])
+
+#seq_proc.plot(time_range=[0,10])
+
+
+sd = h5py.File('gre_jemris_seq2xml_jemris.h5','r')
+sd = sd['seqdiag']
+plt.figure(1)
+plt.subplot(411)
+plt.title("Twice converted JEMRIS sequence diagram")
+
+plt.plot(sd['T'][()],sd['TXM'][()])
+plt.subplot(412)
+plt.plot(sd['T'][()],sd['GX'][()])
+plt.subplot(413)
+plt.plot(sd['T'][()],sd['GY'][()])
+plt.subplot(414)
+plt.plot(sd['T'][()],sd['GZ'][()])
+
+
+
+
+sd = h5py.File('gre.h5','r')
+sd = sd['seqdiag']
+plt.figure(2)
+
+plt.subplot(411)
+plt.title("Original JEMRIS sequence diagram")
+
+plt.plot(sd['T'][()],sd['TXM'][()])
+plt.subplot(412)
+plt.plot(sd['T'][()],sd['GX'][()])
+plt.subplot(413)
+plt.plot(sd['T'][()],sd['GY'][()])
+plt.subplot(414)
+plt.plot(sd['T'][()],sd['GZ'][()])
+
+
+
+
+plt.show()

BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/ext_rf.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/gre.h5


+ 23 - 0
libs/lf-scanner/py2jemris/benchmark_seq2xml/gre.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Parameters FOVx="128" FOVy="128" FOVz="1" Name="P" Nx="32" Ny="32" Nz="1" TE="8" TR="50">
+   <ConcatSequence Name="R">
+      <ConcatSequence Name="C" Observe="NY=P.Ny" Repetitions="NY">
+         <ATOMICSEQUENCE Name="A1">
+            <HARDRFPULSE Axis="RF" Duration="0.1" FlipAngle="20" InitialPhase="C*(C+1)*50" Name="P1" Observe="C=C.Counter"/>
+         </ATOMICSEQUENCE>
+         <DELAYATOMICSEQUENCE Delay="TE" DelayType="C2C" Name="D1" Observe="TE=P.TE" StartSeq="A1" StopSeq="A3"/>
+         <ATOMICSEQUENCE Name="A2">
+            <TRAPGRADPULSE Area="-A/2" Axis="GX" Name="P2" Observe="A=P4.Area"/>
+            <TRAPGRADPULSE Area="-KMY+C*DKY" Axis="GY" Name="P3" Observe="KMY=P.KMAXy, C=C.Counter, DKY=P.DKy"/>
+         </ATOMICSEQUENCE>
+         <ATOMICSEQUENCE Name="A3">
+            <TRAPGRADPULSE ADCs="NX" Axis="GX" FlatTopArea="2*KMX" FlatTopTime="4" Name="P4" Observe="KMX=P.KMAXx, NX=P.Nx" PhaseLock="1"/>
+         </ATOMICSEQUENCE>
+         <ATOMICSEQUENCE Name="A4">
+            <TRAPGRADPULSE Area="1.5*A" Axis="GX" Name="P6" Observe="A=P4.Area"/>
+            <TRAPGRADPULSE Area="-A" Axis="GY" Name="P7" Observe="A=P3.Area"/>
+         </ATOMICSEQUENCE>
+         <DELAYATOMICSEQUENCE Delay="TR" DelayType="B2E" Name="D2" Observe="TR=P.TR" StartSeq="A1"/>
+      </ConcatSequence>
+   </ConcatSequence>
+</Parameters>

File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/py2jemris/benchmark_seq2xml/gre_jemris_seq2xml.xml


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/gre_jemris_seq2xml_jemris.h5


+ 1 - 0
libs/lf-scanner/py2jemris/benchmark_seq2xml/mysimu2.xml

@@ -0,0 +1 @@
+<simulate name="JEMRIS"><sample name="Sample" uri="sample.h5" /><TXcoilarray uri="uniform.xml" /><RXcoilarray uri="uniform.xml" /><parameter ConcomitantFields="0" EvolutionPrefix="evol" EvolutionSteps="0" RandomNoise="0" /><sequence name="Sequence" uri="gre_jemris_seq2xml.xml" /><model name="Bloch" type="CVODE" /></simulate>

BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_1.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_2.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_3.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_4.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_5.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_6.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_7.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/rf_8.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/sample.h5


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/seq_compare.PNG


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/seq_compare_zoomed.PNG


BIN
libs/lf-scanner/py2jemris/benchmark_seq2xml/signals.h5


+ 4 - 0
libs/lf-scanner/py2jemris/benchmark_seq2xml/uniform.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<CoilArray>
+  <IdealCoil/>
+</CoilArray>

+ 115 - 0
libs/lf-scanner/py2jemris/coil2xml.py

@@ -0,0 +1,115 @@
+# Take user inputs and convert them into either coil2xml or
+
+import xml.etree.ElementTree as ET
+
+import h5py
+import matplotlib.pyplot as plt
+import numpy as np
+
+
+
+def coil2xml(b1maps=None, coil_design=None, fov=256, name='coils', out_folder=''):
+    """
+    Inputs
+    ------
+    b1maps : list, optional
+        List of np.ndarray (dtype='complex') maps for all channels
+    coil_design : dict, optional
+        Dictionary containing information on coil design (see documentation)
+    fov : float
+        Field-of-view of coil in mm
+    name : str, optional
+        Name of generated .xml file
+    out_folder : str, optional
+        Path to directory where the output .h5 is stored
+
+
+    Returns
+    -------
+    None
+
+    """
+    if b1maps is None and coil_design is None:
+        raise ValueError("One of b1map and coil_design must be provided")
+
+    # b1 map case
+    if b1maps is not None:
+
+        root = ET.Element('CoilArray')
+
+        for ch in range(len(b1maps)): # for each channel
+            # Check dimensions
+            if len(b1maps[ch].shape) < 2 or len(b1maps[ch].shape) > 3 \
+                    or max(b1maps[ch].shape) != min(b1maps[ch].shape):
+                raise ValueError("b1map must be a 2D square or 3D cubic array: \
+                                  all sides must be equal")
+
+            N = b1maps[ch].shape[0]
+            dim = len(b1maps[ch].shape)
+
+            b1_magnitude = np.absolute(b1maps[ch])
+            b1_phase = np.angle(b1maps[ch])
+
+            # Make h5 file
+            coil_h5_path = name + f'_ch{ch+1}.h5'
+            coil = h5py.File(out_folder + '/' + coil_h5_path, 'a')
+            if 'maps' in coil.keys():
+                del coil['maps']
+            maps = coil.create_group('maps')
+            magnitude = maps.create_dataset('magnitude',b1_magnitude.shape,dtype='f')
+            phase = maps.create_dataset('phase',b1_phase.shape,dtype='f')
+
+
+            # Set h5 file contents
+            if dim == 2:
+                magnitude[:,:] = b1_magnitude
+                phase[:,:] = b1_phase
+
+            elif dim == 3:
+                print('3d!')
+                magnitude[:,:,:] = b1_magnitude
+                phase[:,:,:] = b1_phase
+
+
+            coil.close()
+
+            # Add corresponding coil to .xml tree
+            externalcoil = ET.SubElement(root, "EXTERNALCOIL")
+            externalcoil.set("Points",str(N))
+            externalcoil.set("Name",f"C{ch+1}")
+            externalcoil.set("Filename",coil_h5_path)
+            externalcoil.set("Extent",str(fov)) # fov is in mm
+            externalcoil.set("Dim",str(dim))
+
+        coil_tree = ET.ElementTree(root)
+        coil_tree.write(out_folder + '/' + name + '.xml')
+
+
+
+
+if __name__ == '__main__':
+    # a = h5py.File('coil2xml/sensmaps.h5','a')
+    # print(a['maps'].keys())
+    # print(a['maps']['magnitude'].keys())
+    # map_mag = a['maps']['magnitude']
+    # map_phase = a['maps']['phase']
+    #
+    #
+    # plt.figure(1)
+    #
+    # for ch in range(8):
+    #     plt.subplot(2,8,ch+1)
+    #     plt.imshow(map_mag[f'0{ch}'][()])
+    #     plt.subplot(2,8,ch+9)
+    #     plt.imshow(map_phase[f'0{ch}'][()])
+    #
+    # plt.show()
+
+    b1 = np.ones((32,32))
+    XY = np.meshgrid(np.linspace(0,1,32), np.linspace(0,1,32))
+    X = XY[0]
+    Y = XY[1]
+    b1 = np.sqrt(X**2 + Y**2)
+    plt.imshow(b1)
+    plt.show()
+    coil2xml(b1maps=[b1], fov=200, name='test_coil', out_folder='coil2xml')

+ 30 - 0
libs/lf-scanner/py2jemris/examine_seq_diag.py

@@ -0,0 +1,30 @@
+import h5py
+import matplotlib.pyplot as plt
+import numpy as np
+
+
+sp1 = h5py.File('sim/test0501/gre_test_0417.h5','r')
+sp2 = h5py.File('sim/test0501/g','r')
+
+print(sp1['seqdiag'].keys())
+print(sp2['seqdiag'].keys())
+
+
+name = 'RXP'
+
+plt.figure(1)
+plt.subplot(211)
+plt.title(name + ' (original)')
+plt.plot(sp1['seqdiag/T'], sp1['seqdiag' + f'/{name}'],'*')
+plt.subplot(212)
+plt.title(name + ' (twice)')
+plt.plot(sp2['seqdiag/T'], sp2['seqdiag' + f'/{name}'],'*')
+
+plt.show()
+
+
+rxp_1 = np.array(sp1['seqdiag/RXP'])
+rxp_2 = np.array(sp2['seqdiag/RXP'])
+
+print(rxp_1*180/np.pi)
+print(rxp_2*180/np.pi)

+ 15 - 0
libs/lf-scanner/py2jemris/make_some_seqs.py

@@ -0,0 +1,15 @@
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.opts import Opts
+from pypulseq.make_arbitrary_grad import make_arbitrary_grad
+import numpy as np
+
+seq = Sequence(system=Opts())
+
+gx = make_arbitrary_grad(channel='x',waveform=np.array([1,2,3,4,5,4,3,2,1]))
+seq.add_block(gx)
+
+seq.write("hiseq.seq")
+
+seq2 = Sequence()
+seq2.read('hiseq.seq')
+print(seq2.get_block(1).gx)

+ 597 - 0
libs/lf-scanner/py2jemris/phantom.py

@@ -0,0 +1,597 @@
+# Copyright of the Board of Trustees of Columbia University in the City of New York
+"""
+Numerical phantom generation and access
+"""
+
+import numpy as np
+import scipy.signal as ss
+import h5py
+import matplotlib.pyplot as plt
+
+class Phantom:
+    """Generic numerical phantom for MRI simulations
+
+    The phantom is mainly defined by three matrices of T1, T2, and PD values, respectively.
+    At the moment, each index in the matrix corresponds to a single spin group.
+    The overall physical size is determined by vsize; phantom voxels must be isotropic.
+
+    Parameters
+    ----------
+    T1map : numpy.ndarray
+        Matrix of T1 values in seconds
+    T2map : numpy.ndarray
+        Matrix of T2 values in seconds
+    PDmap : numpy.ndarray
+        Matrix PD values between 0 and 1
+    vsize : float
+        Voxel size in meters (isotropic)
+    dBmap : numpy.ndarray, optional
+        Matrix of B0 magnetic field variation across phantom
+        The default is 0 and means no variation
+    loc : tuple, optional
+        Overall location of phantom in meters; default is (0,0,0)
+
+    Attributes
+    ----------
+    fov : numpy.ndarray
+        [fov_x, fov_y, fov_z]
+        1 x 3 array of fields-of-view in x, y, and z directions
+    Xs : numpy.ndarray
+        1D array of all x locations in phantom
+    Ys : numpy.ndarray
+        1D array of all y locations in phantom
+    Zs : numpy.ndarray
+        1D array of all z locations in phantom
+
+    """
+    def __init__(self,T1map,T2map,PDmap,vsize,dBmap=0,loc=(0,0,0)):
+        self.vsize = vsize
+        self.T1map = T1map
+        self.T2map = T2map
+        self.PDmap = PDmap
+        self.vsize = vsize
+        self.dBmap = dBmap
+        self.loc = loc
+
+        # Find field-of-view
+        self.fov = vsize*np.array(np.shape(T1map))
+
+        # Make location vectors
+        ph_shape = np.shape(self.PDmap)
+
+        # Define coordinates
+        self.Xs = self.loc[0]+np.arange(-self.fov[0] / 2 + vsize / 2, self.fov[0] / 2, vsize)
+        self.Ys = self.loc[1]+np.arange(-self.fov[1] / 2 + vsize / 2, self.fov[1] / 2, vsize)
+        self.Zs = self.loc[2]+np.arange(-self.fov[2] / 2 + vsize / 2, self.fov[2] / 2, vsize)
+
+    def get_location(self,indx):
+        """Returns (x,y,z) physical location in meters at given indices
+
+        Parameters
+        ----------
+        indx : tuple or array_like
+            (ind1, ind2, ind3)
+            Index for querying
+
+        Returns
+        -------
+        x, y, z : float
+            physical location corresponding to index
+
+        """
+        return self.Xs[indx[0]], self.Ys[indx[1]], self.Zs[indx[2]]
+
+    def get_shape(self):
+        """Returns the phantom's matrix size
+
+        Returns
+        -------
+        shape : tuple
+            The matrix size in three dimensions
+
+        """
+        return np.shape(self.PDmap)
+
+    def get_params(self,indx):
+        """Returns PD, T1, and T2 at given indices
+
+        Parameters
+        ----------
+        indx : tuple
+            Index for querying
+
+        Returns
+        -------
+        PD, T1, T2 : float
+            Tissue parameters corresponding to the queried index
+
+        """
+        return self.PDmap[indx],self.T1map[indx],self.T2map[indx]
+
+    def get_list_locs(self):
+        """Returns a flattened 1D array of all location vectors [(x1,y1,z1),...,(xk,yk,zk)]
+
+        Returns
+        -------
+        list_locs : list
+
+        """
+        list_locs = []
+        for x in self.Xs:
+            for y in self.Ys:
+                for z in self.Zs:
+                    list_locs.append((x, y, z))
+        return list_locs
+
+    def get_list_inds(self):
+        """Returns a flattened 1D array of all indices in phantom [(u1,v1,w1),...,(uk,vk,wk)]
+
+        Returns
+        -------
+        list_inds : list
+
+        """
+        list_inds = []
+        sh = self.get_shape()
+        for u in range(sh[0]):
+            for v in range(sh[1]):
+                for w in range(sh[2]):
+                    list_inds.append((u,v,w))
+        return list_inds
+
+    def output_h5(self, output_folder, name='phantom'):
+        """
+        Inputs
+        ------
+        output_folder : str
+            Folder in which to output the h5 file
+        """
+
+        GAMMA = 2 * 42.58e6 * np.pi
+
+        pht_shape = list(np.flip(self.get_shape()))
+        dim = len(pht_shape)
+
+        pht_shape.append(5)
+
+
+        PDmap_au = np.swapaxes(self.PDmap,0,-1)
+        T1map_ms = np.swapaxes(self.T1map * 1e3, 0,-1)
+        T2map_ms = np.swapaxes(self.T2map * 1e3,0,-1)
+
+        T1map_ms_inv = np.where(T1map_ms > 0, 1/T1map_ms, 0)
+        T2map_ms_inv = np.where(T2map_ms > 0, 1/T2map_ms, 0)
+
+
+        if np.shape(self.dBmap) == tuple(pht_shape):
+            dBmap_rad_per_ms = np.swapaxes(self.dBmap * GAMMA * 1e-3, 0, -1)
+        else:
+            dBmap_rad_per_ms = self.dBmap * GAMMA * 1e-3
+
+
+
+
+        if len(output_folder) > 0:
+            output_folder += '/'
+        pht_file = h5py.File(output_folder + name + '.h5', 'a')
+        if "sample" in pht_file.keys():
+            del pht_file["sample"]
+
+        sample = pht_file.create_group('sample')
+
+        data = sample.create_dataset('data', tuple(pht_shape),
+                                     dtype='f')  # M0, 1/T1 [1/ms], 1/T2 [1/ms], 1/T2* [1/ms], chemical shift [rad/ms]
+        offset = sample.create_dataset('offset', (3, 1), dtype='f')
+        resolution = sample.create_dataset('resolution', (3, 1), dtype='f')
+
+        if dim == 1:
+            data[:, 0] = PDmap_au
+            #data[:, 1] = 1 / T1map_ms
+            data[:, 1] = T1map_ms_inv
+            #data[:, 2] = 1 / T2map_ms
+            data[:, 2] = T2map_ms_inv
+            #data[:, 3] = 1 / T2map_ms  # T2 assigned as T2* for now
+            data[:, 3] = T2map_ms_inv
+            data[:, 4] = dBmap_rad_per_ms
+
+        elif dim == 2:
+            data[:, :, 0] = PDmap_au
+            #data[:, :, 1] = 1 / T1map_ms
+            #data[:, :, 2] = 1 / T2map_ms
+            #data[:, :, 3] = 1 / T2map_ms  # T2 assigned as T2* for now
+            data[:, :, 1] = T1map_ms_inv
+            data[:, :, 2] = T2map_ms_inv
+            data[:, :, 3] = T2map_ms_inv
+            data[:, :, 4] = dBmap_rad_per_ms
+
+        elif dim == 3:
+            data[:, :, :, 0] = PDmap_au
+            #data[:, :, :, 1] = 1 / T1map_ms
+            #data[:, :, :, 2] = 1 / T2map_ms
+            #data[:, :, :, 3] = 1 / T2map_ms  # T2 assigned as T2* for now
+            data[:, :, :, 1] = T1map_ms_inv
+            data[:, :, :, 2] = T2map_ms_inv
+            data[:, :, :, 3] = T2map_ms_inv
+            data[:, :, :, 4] = dBmap_rad_per_ms
+
+
+        offset[:,0] = np.array(self.loc)*1000 # meters to mm conversion
+        resolution[:,0] = [self.vsize*1000]*3 # isotropic
+
+        pht_file.close()
+
+        return
+
+class DTTPhantom(Phantom):
+    """Discrete tissue type phantom
+
+    Phantom constructed from a finite set of tissue types and their parameters
+
+    Parameters
+    ----------
+    type_map : numpy.ndarray
+        Matrix of integers that map to tissue types
+    type_params : dict
+        Dictionary that maps tissue type number to tissue type parameters (PD,T1,T2)
+    vsize : float
+        Voxel size in meters (isotropic)
+    dBmap : numpy.ndarray, optional
+        Matrix of B0 magnetic field variation across phantom
+        The default is 0 and means no variation
+    loc : tuple, optional
+        Overall location of phantom; default is (0,0,0)
+
+    """
+
+    def __init__(self,type_map,type_params,vsize,dBmap=0,loc=(0,0,0)):
+        print(type(type_map))
+        self.type_map = type_map
+        self.type_params = type_params
+        T1map = np.ones(np.shape(type_map))
+        T2map = np.ones(np.shape(type_map))
+        PDmap = np.zeros(np.shape(type_map))
+
+        for x in range(np.shape(type_map)[0]):
+            for y in range(np.shape(type_map)[1]):
+                for z in range(np.shape(type_map)[2]):
+                    PDmap[x,y,z] = type_params[type_map[x,y,z]][0]
+                    T1map[x,y,z] = type_params[type_map[x,y,z]][1]
+                    T2map[x,y,z] = type_params[type_map[x,y,z]][2]
+
+        super().__init__(T1map,T2map,PDmap,vsize,dBmap,loc)
+
+
+class BrainwebPhantom(Phantom):
+    """This phantom is in development.
+
+    """
+
+    def __init__(self, filename,dsf=1,make2d=False,loc=0,dir='z',dBmap=0):
+        dsf = int(np.absolute(dsf))
+        bw_data = np.load(filename).all()
+        params = {k: np.array([v[3],v[0],v[1]]) for k, v in bw_data['params'].items()}
+
+        typemap =  bw_data['typemap']
+
+        dr = 1e-3 # 1mm voxel size
+
+        # If we want planar phantom, then let's take the slice!
+        if make2d:
+            if dir in ['sagittal','x']:
+                n = np.shape(typemap)[0]
+                xx = dr*(n-1)
+                loc_ind = int((n/xx)*loc + n/2)
+                if loc_ind < 0:
+                    loc_ind = 0
+                if loc_ind > n-1:
+                    loc_ind = n-1
+                typemap = typemap[[loc_ind],:,:]
+
+            elif dir in ['coronal','y']:
+                n = np.shape(typemap)[1]
+                yy = dr*(n-1)
+                loc_ind = int((n/yy)*loc + n/2)
+                if loc_ind < 0:
+                    loc_ind = 0
+                if loc_ind > n - 1:
+                    loc_ind = n - 1
+                typemap = typemap[:,[loc_ind],:]
+
+            elif dir in ['axial','z']:
+                n = np.shape(typemap)[2]
+                zz = dr*(n-1)
+                loc_ind = int((n/zz)*loc + n/2)
+                if loc_ind < 0:
+                    loc_ind = 0
+                if loc_ind > n - 1:
+                    loc_ind = n - 1
+                typemap = typemap[:,:,[loc_ind]]
+
+        # Make parm maps from typemap
+        a,b,c = np.shape(typemap)
+        T1map = np.ones((a,b,c))
+        T2map = np.ones((a,b,c))
+        PDmap = np.zeros((a,b,c))
+
+        for x in range(a):
+            for y in range(b):
+                for z in range(c):
+                    PDmap[x,y,z] = params[typemap[x,y,z]][0]
+                    T1map[x,y,z] = params[typemap[x,y,z]][1]
+                    T2map[x,y,z] = params[typemap[x,y,z]][2]
+
+        # Downsample maps
+        a,b,c = np.shape(PDmap)
+
+        if a == 1:
+            ax = [1,2]
+        elif b == 1:
+            ax = [0,2]
+        elif c == 1:
+            ax = [0,1]
+        else:
+            ax = [0,1,2]
+
+        for v in range(len(ax)):
+            PDmap = ss.decimate(PDmap, dsf, axis=ax[v], ftype='fir')
+            T1map = ss.decimate(T1map, dsf, axis=ax[v], ftype='fir')
+            T2map = ss.decimate(T2map, dsf, axis=ax[v], ftype='fir')
+
+
+        dr = dr*dsf
+        PDmap = np.clip(PDmap,a_min=0,a_max=1)
+        T1map = np.clip(T1map,a_min=0,a_max=None)
+        T2map = np.clip(T2map,a_min=0,a_max=None)
+
+        super().__init__(T1map,T2map,PDmap,dr,dBmap)
+
+
+class SpheresArrayPlanarPhantom(DTTPhantom):
+    """2D phantom extracted from a cylinder containing spheres
+
+    Regardless of dir, this will be an axial slice of a cylinder
+    That is, the plane is constructed as a z-slice and then re-indexed to lie in the x or y plane
+    The centers of spheres will correspond to locations before re-indexing
+
+    Parameters
+    ----------
+    centers : list or array_like
+        List of 3D physical locations of the spheres' centers
+    radii : list or array_like
+        List of radii for the spheres
+    type_params : dict
+        Dictionary that maps tissue type number to tissue type parameters (PD,T1,T2)
+    fov : float
+        Field of view (isotropic)
+    n : int
+        Matrix size
+    dir : str, optional {'z','x','y'}
+        Orientation of plane; default is z
+    R : float, optional
+        Cylinder's cross-section radius; default is half of fov
+    loc : tuple, optional
+        Overall location (x,y,z) of phantom from isocenter in meters
+        Default is (0,0,0)
+
+
+    """
+    def __init__(self, centers, radii, type_params, fov, n, dir='z',R=0,loc=(0,0,0)):
+        if R == 0:
+            R = fov/2
+        vsize = fov/n
+        type_map = np.zeros((n,n,1))
+        q = (n-1)/2
+        centers_inds = [(np.array(c) / vsize + q) for c in centers]
+        nc = len(centers)
+        for r1 in range(n):
+            for r2 in range(n):
+                if vsize * np.sqrt((r1-q)**2+(r2-q)**2)<R:
+                    type_map[r1,r2,0] = nc + 1
+                for k in range(len(centers_inds)):
+                    ci = centers_inds[k]
+                    d = vsize * np.sqrt((r1 - ci[0]) ** 2 + (r2 - ci[1])**2)
+                    if d <= radii[k]:
+                        type_map[r1,r2,0] = k + 1
+                        break
+        if dir == 'x':
+            type_map = np.swapaxes(type_map, 1, 2)
+            type_map = np.swapaxes(type_map, 0, 1)
+        elif dir == 'y':
+            type_map = np.swapaxes(type_map, 0, 2)
+            type_map = np.swapaxes(type_map, 0, 1)
+
+        super().__init__(type_map, type_params, vsize, loc=loc)
+
+
+def makeSphericalPhantom(n,fov,T1s,T2s,PDs,radii,loc=(0,0,0)):
+    """Make a spherical phantom with concentric layers
+
+    Parameters
+    ----------
+    n : int
+        Matrix size of phantom (isotropic)
+    fov : float
+        Field of view of phantom (isotropic)
+    T1s : numpy.ndarray or list
+        List of T1s in seconds for the layers, going outward
+    T2s : numpy.ndarray or list
+        List of T2s in seconds for the layers, going outward
+    PDs : numpy.ndarray or list
+        List of PDs between 0 and 1 for the layers, going outward
+    radii : numpy.ndarray
+        List of radii that define the layers
+        Note that the radii are expected to go from smallest to largest
+        If not, they will be sorted first without sorting the parameters
+    loc : tuple, optional
+        Overall (x,y,z) location of phantom; default is (0,0,0)
+
+    Returns
+    -------
+    phantom : DTTPhantom
+
+    """
+    radii = np.sort(radii)
+    m = np.shape(radii)[0]
+    vsize = fov/n
+    type_map = np.zeros((n,n,n))
+    type_params = {}
+    for x in range(n):
+        for y in range(n):
+            for z in range(n):
+                d = vsize*np.linalg.norm(np.array([x,y,z])-(n-1)/2)
+                for k in range(m):
+                    if d <= radii[k]:
+                        type_map[x,y,z] = k+1
+                        break
+
+    type_params[0] = (0,1,1)
+    for k in range(m):
+        type_params[k+1] = (PDs[k],T1s[k],T2s[k])
+
+    return DTTPhantom(type_map,type_params,vsize,loc)
+
+
+def makePlanarPhantom(n,fov,T1s,T2s,PDs,radii,dir='z',loc=(0,0,0)):
+    """Make a circular 2D phantom with concentric layers
+
+    Parameters
+    ----------
+    n : int
+        Matrix size of phantom (isotropic)
+    fov : float
+        Field of view of phantom (isotropic)
+    T1s : numpy.ndarray or list
+        List of T1s in seconds for the layers, going outward
+    T2s : numpy.ndarray or list
+        List of T2s in seconds for the layers, going outward
+    PDs : numpy.ndarray or list
+        List of PDs between 0 and 1 for the layers, going outward
+    radii : numpy.ndarray
+        List of radii that define the layers
+        Note that the radii are expected to go from smallest to largest
+        If not, they will be sorted first without sorting the parameters
+    dir : str, optional {'z','x','y'}
+         Orientation of the plane; default is z, axial
+    loc : tuple, optional
+        Overall (x,y,z) location of phantom; default is (0,0,0)
+
+    Returns
+    -------
+    phantom : DTTPhantom
+
+    """
+    radii = np.sort(radii)
+    m = np.shape(radii)[0]
+    vsize = fov / n
+    type_map = np.zeros((n, n, 1))
+    type_params = {}
+    for x in range(n):
+        for y in range(n):
+                d = vsize * np.linalg.norm(np.array([x, y]) - (n - 1) / 2)
+                for k in range(m):
+                    if d <= radii[k]:
+                        type_map[x,y,0] = k + 1
+                        break
+
+    type_params[0] = (0, 1, 1)
+    for k in range(m):
+        type_params[k + 1] = (PDs[k], T1s[k], T2s[k])
+
+    if dir == 'x':
+        type_map = np.swapaxes(type_map,1,2)
+        type_map = np.swapaxes(type_map,0,1)
+    elif dir =='y':
+        type_map = np.swapaxes(type_map,0,2)
+        type_map = np.swapaxes(type_map,0,1)
+
+    return DTTPhantom(type_map, type_params, vsize, loc)
+
+
+def makeCylindricalPhantom(dim=2,n=16,dir='z',loc=0,fov=0.24):
+    """Makes a cylindrical phantom with fixed geometry and T1, T2, PD but variable resolution and overall size
+
+    The cylinder's diameter is the same as its height; three layers of spheres represent T1, T2, and PD variation.
+
+    Parameters
+    ----------
+    dim : int, optional {2,3}
+         Dimension of phantom created
+    n : int
+        Number of spin groups in each dimension; default is 16
+    dir : str, optional {'z', 'x', 'y'}
+        Direction (norm) of plane in the case of 2D phantom
+    loc : float, optional
+        Location of plane relative to isocenter; default is 0
+    fov : float, optional
+        Physical length for both diameter and height of cylinder
+
+    Returns
+    -------
+    phantom : DTTPhantom
+
+    """
+    R = fov/2 # m
+    r = R/4 # m
+    h = fov # m
+    s2 = np.sqrt(2)
+    s3 = np.sqrt(3)
+    vsize = fov/n
+    centers = [(0,R/2,0.08),(-R*s3/4,-R/4,0.08),(R*s3/4,-R/4,0.08), # PD spheres
+               (R/(2*s2),R/(2*s2),0),(-R/(2*s2),R/(2*s2),0),(-R/(2*s2),-R/(2*s2),0),(R/(2*s2),-R/(2*s2),0), # T1 spheres
+               (0,R/2,-0.08),(-R/2,0,-0.08),(0,-R/2,-0.08),(R/2,0,-0.08)] # T2 spheres
+    centers_inds = [(np.array(c)/vsize + (n-1)/2) for c in centers]
+
+    type_params = {0:(0,1,1), # background
+                   1:(1,0.5,0.1),2:(0.75,0.5,0.1),3:(0.5,0.5,0.1), # PD spheres
+                4:(0.75,1.5,0.1),5:(0.75,0.6,0.1),6:(0.75,0.25,0.1),7:(0.75,0.1,0.1), # T1 spheres
+                8:(0.75,0.5,0.5),9:(0.75,0.5,0.15),10:(0.75,0.5,0.05),11:(0.75,0.5,0.01), # T2 spheres
+                13:(0.25,0.5,0.1)}
+
+    q = (n - 1) / 2
+    p = 'xyz'.index(dir)
+    pht_loc = (0, 0, 0)
+
+    if dim == 3:
+        type_map = np.zeros((n, n, n))
+        for x in range(n):
+            for y in range(n):
+                for z in range(n):
+                    if vsize*np.sqrt((x-q)**2 + (y-q)**2) < R:
+                        type_map[x,y,z] = 13
+                    for k in range(len(centers_inds)):
+                        ci = centers_inds[k]
+                        d = vsize*np.sqrt((x-ci[0])**2+(y-ci[1])**2+(z-ci[2])**2)
+                        if d <= r:
+                            type_map[x, y, z] = k + 1
+                            break
+
+    elif dim == 2:
+        pht_loc = np.roll((loc,0,0),p)
+        # 2D phantom
+        type_map = np.zeros(np.roll((1,n,n),p))
+        for r1 in range(n):
+            for r2 in range(n):
+                x,y,z = np.roll([q+loc/vsize,r1,r2],p)
+                u,v,w = np.roll((0,r1,r2),p)
+                if vsize*np.sqrt((x-q)**2 + (y-q)**2) < R:
+                    type_map[u,v,w] = 13
+                    for k in range(len(centers_inds)):
+                        ci = centers_inds[k]
+                        d = vsize*np.sqrt((x-ci[0])**2+(y-ci[1])**2+(z-ci[2])**2)
+                        if d <= r:
+                            type_map[u,v,w] = k + 1
+                            break
+
+
+    else:
+        raise ValueError('#Dimensions must be 2 or 3')
+
+    phantom = DTTPhantom(type_map, type_params, vsize, loc=(0,0,0))
+    return phantom
+
+
+if __name__ == '__main__':
+    pht = makeCylindricalPhantom(dim=2, n=16, dir='z', loc=-0.08, fov = 0.25)
+    plt.imshow(pht.PDmap)
+    plt.show()
+    print(pht.loc)

+ 33 - 0
libs/lf-scanner/py2jemris/pull_request_template.md

@@ -0,0 +1,33 @@
+# Description
+
+Summarize feature additions, code improvements, or issue fixes. Include relevant motivation and context. List any dependencies with versions specified. 
+
+If responding to a posted Github Issue, refer to it here. 
+e.g. This fixes issue #(issue number). 
+
+## Type of change
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] This change requires a documentation update
+
+# How Has This Been Tested?
+
+Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
+
+- [ ] Test A
+- [ ] Test B
+
+**Test Configuration**:
+* Operating system: 
+* Python version: 
+
+# Checklist:
+
+- [ ] My code follows the style guidelines of this project
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented my code, particularly in hard-to-understand areas and changes upon existing code 
+- [ ] I have made corresponding changes to the documentation
+- [ ] I have added tests that prove my fix is effective or that my feature works
+- [ ] New and existing unit tests pass locally with my changes

+ 210 - 0
libs/lf-scanner/py2jemris/pulseq_jemris_simulator.py

@@ -0,0 +1,210 @@
+# Same role as pulseq_bloch_simulator.py except (1) It uses JEMRIS (2) It is run as a function, not a script
+
+# INPUTS: sequence type, geometry parameters, contrast parameters,
+#         phantom type (pre-set or custom), coil type (pre-set or custom) (Tx / Rx),
+#         k-space trajectory (if noncartesian; flattened version (Nro_total, 3))
+import time
+import os
+from sim_jemris import sim_jemris
+from recon_jemris import read_jemris_output, recon_jemris
+from coil2xml import coil2xml
+#from virtualscanner.core import constants
+from seq2xml import seq2xml
+from pypulseq.Sequence.sequence import Sequence
+import phantom as pht
+import numpy as np
+import xml.etree.ElementTree as ET
+from pulseq_library import make_pulseq_se_oblique,make_pulseq_gre_oblique, make_pulseq_irse_oblique
+from scipy.io import savemat, loadmat
+
+def simulate_pulseq_jemris(seq_path, phantom_info, coil_fov,
+                           tx='uniform', rx='uniform', # TODO add input that includes sequence info for
+                                                       # TODO      dimensioning the RO points into kspace
+                           tx_maps=None, rx_maps=None, sim_name=None, env_option="local"):
+    """Runs simulation using an already-made .seq file
+
+    Inputs
+    ------
+    seq_path : str
+        Path to seq file
+    phantom_info : dict
+        Information used to create phantom; input to create_and_save_phantom()
+    coil_fov : float
+        Field-of-view of coil in mm
+    tx : str, optional
+        Type of tx coil; default is 'uniform'; the only other option is 'custom'
+    rx : str, optional
+        Type of rx coil; default is 'uniform'; the only other option is 'custom'
+    tx_maps : list, optional
+        List of np.ndarray (dtype='complex') maps for all tx channels
+        Required for 'custom' type tx
+    rx_maps : list, optional
+        List of np.ndarray (dtype='complex') maps for all rx channels
+        Required for 'custom' type rx
+    sim_name : str, optional
+        Used as folder name inside sim folder
+        Default is None, in which case sim_name will be set to the current timestamp
+
+    Returns
+    -------
+    sim_output :
+        Delivers output from sim_jemris
+
+    """
+    if sim_name is None:
+        sim_name = time.strftime("%Y%m%d%H%M%S")
+
+    if env_option == 'local':
+        target_path =  'sim\\' +  sim_name
+    elif env_option == 'colab':
+        target_path = 'sim\\' + sim_name
+
+    # Make target folder
+    dir_str = f'sim\\{sim_name}'
+    if not os.path.isdir(dir_str):
+        os.system(f'mkdir {dir_str}')
+
+    # Convert .seq to .xml
+    seq = Sequence()
+    seq.read(seq_path)
+    print(seq.get_block(1))
+    seq_name = seq_path[seq_path.rfind('/')+1:seq_path.rfind('.seq')]
+    seq2xml(seq, seq_name=seq_name, out_folder=str(target_path))
+
+    # Make phantom and save as .h5 file
+    pht_name = create_and_save_phantom(phantom_info, out_folder=target_path)
+
+
+    # Make sure we have the tx/rx files
+    tx_filename = tx + '.xml'
+    rx_filename = rx + '.xml'
+
+    # Save Tx as xml
+    if tx == 'uniform':
+        cp_command = f'copy {os.getcwd()}\\sim\\{tx}.xml {os.getcwd()}\\{str(target_path)}\\{tx}.xml'
+        print(cp_command)
+        a = os.system(cp_command)
+        print(a)
+    elif tx == 'custom' and tx_maps is not None:
+        coil2xml(b1maps=tx_maps, fov=coil_fov, name='custom_tx', out_folder=target_path)
+        tx_filename = 'custom_tx.xml'
+    else:
+        raise ValueError('Tx coil type not found')
+
+    # save Rx as xml
+    if rx == 'uniform':
+        os.system(f'copy sim\\{rx}.xml sim\\{str(target_path)}')
+    elif rx == 'custom' and rx_maps is not None:
+        coil2xml(b1maps=rx_maps, fov=coil_fov, name='custom_rx', out_folder=target_path)
+        rx_filename = 'custom_rx.xml'
+    else:
+        raise ValueError('Rx coil type not found')
+
+    # Run simuluation in target folder
+    list_sim_files = {'seq_xml': seq_name+'.xml', 'pht_h5': pht_name + '.h5', 'tx_xml': tx_filename,
+                       'rx_xml': rx_filename}
+    sim_output = sim_jemris(list_sim_files=list_sim_files, working_folder=target_path)
+
+    return sim_output
+
+
+# TODO
+def create_and_save_phantom(phantom_info, out_folder):
+    """Generates a phantom and saves it into desired folder as .h5 file for JEMRIS purposes
+
+    Inputs
+    ------
+    phantom_info : dict
+        Info of phantom to be constructed
+
+        REQUIRED
+        'fov' : float, field-of-view [meters]
+        'N' : int, phantom matrix size (isotropic)
+        'type' : str, 'spherical', 'cylindrical' or 'custom'
+        'dim' : int, either 3 or 2; 3D or 2D phantom options
+        'dir' : str, {'x', 'y', 'z'}; orientation of 2D phantom
+
+        OPTIONAL (only required for 'custom' phantom type)
+        'T1' : np.ndarray, T1 map matrix
+        'T2' : np.ndarray, T2 map matrix
+        'PD' : np.ndarray, PD map matrix
+        'dr' : float, voxel size [meters] (isotropic)
+        'dBmap' : optional even for 'custom' type. If not provided, dB is set to 0 everywhere.
+
+
+    out_folder : str or pathlib Path object
+        Path to directory where phantom will be saved
+
+    Returns
+    -------
+    pht_type : str
+        phantom_info['pht_type'] (returned for naming purposes)
+
+
+    """
+    out_folder = str(out_folder)
+
+    FOV = phantom_info['fov']
+    N = phantom_info['N']
+    pht_type = phantom_info['type']
+    pht_dim = phantom_info['dim']
+    pht_dir = phantom_info['dir']
+    pht_loc = phantom_info['loc']
+
+    sim_phantom = 0
+
+    if pht_type == 'spherical':
+        print('Making spherical phantom')
+        T1s = [1000*1e-3]
+        T2s = [100*1e-3]
+        PDs = [1]
+        R = 0.8*FOV/2
+        Rs = [R]
+        if pht_dim == 3:
+            sim_phantom = pht.makeSphericalPhantom(n=N, fov=FOV, T1s=T1s, T2s=T2s, PDs=PDs, radii=Rs)
+
+        elif pht_dim == 2:
+            sim_phantom = pht.makePlanarPhantom(n=N, fov=FOV, T1s=T1s, T2s=T2s, PDs=PDs, radii=Rs,
+                                                dir=pht_dir, loc=0)
+    elif pht_type == 'cylindrical':
+        print("Making cylindrical phantom")
+        sim_phantom = pht.makeCylindricalPhantom(dim=pht_dim, n=N, dir=pht_dir, loc=pht_loc)
+
+    elif pht_type == 'custom':
+        # Use a custom file!
+        T1 = phantom_info['T1']
+        T2 = phantom_info['T2']
+        PD = phantom_info['PD']
+        dr = phantom_info['dr']
+        if 'dBmap' in phantom_info.keys():
+            dBmap = phantom_info['dBmap']
+        else:
+            dBmap = 0
+
+        sim_phantom = pht.Phantom(T1map=T1, T2map=T2, PDmap=PD, vsize=dr, dBmap=dBmap, loc=(0,0,0))
+
+    else:
+        raise ValueError("Phantom type non-existent!")
+
+    # Save as h5
+    sim_phantom.output_h5(out_folder, pht_type)
+
+    return pht_type
+
+if __name__ == '__main__':
+    # Define the same phantom
+    phantom_info = {'fov': 0.256, 'N': 32, 'type': 'cylindrical', 'dim':2, 'dir':'z'}
+    sim_names = ['test0413_GRE', 'test0413_SE', 'test0413_IRSE']
+    sps = ['gre_fov256mm_Nf15_Np15_TE50ms_TR200ms_FA90deg.seq',
+           'se_fov256mm_Nf15_Np15_TE50ms_TR200ms_FA90deg.seq',
+           'irse_fov256mm_Nf15_Np15_TI20ms_TE50ms_TR200ms_FA90deg.seq']
+    # make_pulseq_irse_oblique(fov=0.256,n=15, thk=0.005, tr=0.2, te=0.05, ti=0.02, fa=90,
+    #                          enc='xyz', slice_locs=[0], write=True)
+    # make_pulseq_gre_oblique(fov=0.256,n=15, thk=0.005, tr=0.2, te=0.05, fa=90,
+    #                          enc='xyz', slice_locs=[0], write=True)
+    # make_pulseq_se_oblique(fov=0.256,n=15, thk=0.005, tr=0.2, te=0.05, fa=90,
+    #                          enc='xyz', slice_locs=[0], write=True)
+    simulate_pulseq_jemris(seq_path=sps[0], phantom_info=phantom_info, sim_name=sim_names[0],
+                               coil_fov=0.256)
+    kk, im, images = recon_jemris(file='sim/' + sim_names[0] + '/signals.h5', dims=[15,15])
+    savemat('sim/'+sim_names[0]+'/output.mat', {'images': images, 'kspace': kk, 'imspace': im})

+ 1150 - 0
libs/lf-scanner/py2jemris/pulseq_library.py

@@ -0,0 +1,1150 @@
+# Copyright of the Board of Trustees of Columbia University in the City of New York
+"""
+Library for generating pulseq sequences: GRE, SE, IRSE, EPI
+"""
+
+# TODO update for PyPulseq 1.2.1
+
+import copy
+from math import pi, sqrt, ceil, floor
+
+import numpy as np
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_trap_pulse import make_trapezoid
+from pypulseq.opts import Opts
+
+GAMMA_BAR = 42.5775e6
+GAMMA = 2*pi*GAMMA_BAR
+
+
+def make_pulseq_gre(fov,n,thk,fa,tr,te,enc='xyz',slice_locs=None,write=False):
+    """Makes a gradient-echo sequence
+
+    2D orthogonal multi-slice gradient-echo pulse sequence with Cartesian encoding
+    Orthogonal means that each of slice-selection, phase encoding, and frequency encoding
+    aligns with the x, y, or z directions
+
+    Parameters
+    ----------
+    fov : float
+        Field-of-view in meters (isotropic)
+    n : int
+        Matrix size (isotropic)
+    thk : float
+        Slice thickness in meters
+    fa : float
+        Flip angle in degrees
+    tr : float
+        Repetition time in seconds
+    te : float
+        Echo time in seconds
+    enc : str, optional
+        Spatial encoding directions
+        1st - readout; 2nd - phase encoding; 3rd - slice select
+        Default 'xyz' means axial(z) slice with readout in x and phase encoding in y
+    slice_locs : array_like, optional
+        Slice locations from isocenter in meters
+        Default is None which means a single slice at the center
+    write : bool, optional
+        Whether to write seq into file; default is False
+
+    Returns
+    -------
+    seq : Sequence
+        Pulse sequence as a Pulseq object
+
+    """
+    system = Opts(max_grad=32, grad_unit='mT/m', max_slew=130,
+                  slew_unit='T/m/s', rf_ringdown_time=30e-6,
+                  rf_dead_time=100e-6, adc_dead_time=20e-6)
+    seq = Sequence(system)
+
+
+    Nf = n
+    Np = n
+    flip = fa * pi / 180
+    rf, g_ss, __ = make_sinc_pulse(flip_angle=flip, system=system, duration=4e-3, slice_thickness=thk,
+                               apodization=0.5, time_bw_product=4)
+
+    g_ss.channel = enc[2]
+
+    delta_k = 1 / fov
+    kWidth = Nf * delta_k
+
+    # Readout and ADC
+    readoutTime = 6.4e-3
+    g_ro= make_trapezoid(channel=enc[0],system=system,flat_area=kWidth,flat_time=readoutTime)
+    adc = make_adc(num_samples=Nf, duration=g_ro.flat_time, delay=g_ro.rise_time)
+
+    # Readout rewinder
+    g_ro_pre = make_trapezoid(channel=enc[0],system=system,area=-g_ro.area/2,duration=2e-3)
+    # Slice refocusing
+    g_ss_reph = make_trapezoid(channel=enc[2],system=system,area=-g_ss.area/2,duration=2e-3)
+    phase_areas = (np.arange(Np) - (Np / 2)) * delta_k
+
+    # TE, TR = 10e-3, 1000e-3
+    TE, TR = te,tr
+    delayTE = TE - calc_duration(g_ro_pre) - calc_duration(g_ss) / 2 - calc_duration(g_ro) / 2
+    delayTR = TR - calc_duration(g_ro_pre) - calc_duration(g_ss) - calc_duration(g_ro) - delayTE
+    delay1 = make_delay(delayTE)
+    delay2 = make_delay(delayTR)
+
+    if slice_locs is None:
+        locs = [0]
+    else:
+        locs = slice_locs
+
+    for u in range(len(locs)):
+        # add frequency offset
+        rf.freq_offset = g_ss.amplitude * locs[u]
+        for i in range(Np):
+            seq.add_block(rf, g_ss)
+            g_pe = make_trapezoid(channel=enc[1],system=system,area=phase_areas[i],duration=2e-3)
+            seq.add_block(g_ro_pre, g_pe, g_ss_reph)
+            seq.add_block(delay1)
+            seq.add_block(g_ro,adc)
+            seq.add_block(delay2)
+
+    if write:
+        seq.write("gre_fov{:.0f}mm_Nf{:d}_Np{:d}_TE{:.0f}ms_TR{:.0f}ms_FA{:.0f}deg.seq".format(fov * 1000, Nf, Np, TE * 1000,
+                                                                                               TR * 1000, flip * 180 / pi))
+    print('GRE sequence constructed')
+    return seq
+
+
+def make_pulseq_gre_oblique(fov,n,thk,fa,tr,te,enc='xyz',slice_locs=None,write=False):
+    """Makes a gradient-echo sequence in any plane
+
+        2D oblique multi-slice gradient-echo pulse sequence with Cartesian encoding
+        Oblique means that each of slice-selection, phase encoding, and frequency encoding
+        can point in any specified direction
+
+        Parameters
+        ----------
+        fov : array_like
+            Isotropic field-of-view, or length-2 list [fov_readout, fov_phase], in meters
+        n : array_like
+            Isotropic matrix size, or length-2 list [n_readout, n_phase]
+        thk : float
+            Slice thickness in meters
+        fa : float
+            Flip angle in degrees
+        tr : float
+            Repetition time in seconds
+        te : float
+            Echo time in seconds
+        enc : str or array_like, optional
+            Spatial encoding directions
+            1st - readout; 2nd - phase encoding; 3rd - slice select
+            - Use str with any permutation of x, y, and z to obtain orthogonal slices
+            e.g. The default 'xyz' means axial(z) slice with readout in x and phase encoding in y
+            - Use list to indicate vectors in the encoding directions for oblique slices
+            They should be perpendicular to each other, but not necessarily unit vectors
+            e.g. [(2,1,0),(-1,2,0),(0,0,1)] rotates the two in-plane encoding directions for an axial slice
+        slice_locs : array_like, optional
+            Slice locations from isocenter in meters
+            Default is None which means a single slice at the center
+        write : bool, optional
+            Whether to write seq into file; default is False
+
+        Returns
+        -------
+        seq : Sequence
+            Pulse sequence as a Pulseq object
+
+        """
+
+    # System options
+    system = Opts(max_grad=32, grad_unit='mT/m', max_slew=130,
+                  slew_unit='T/m/s', rf_ringdown_time=30e-6,
+                  rf_dead_time=100e-6, adc_dead_time=20e-6)
+    seq = Sequence(system)
+
+    # Calculate unit gradients for ss, fe, pe
+    ug_fe, ug_pe, ug_ss = parse_enc(enc)
+
+    # Sequence parameters
+    Nf, Np = (n,n) if isinstance(n,int) else (n[0], n[1])
+    delta_k_ro, delta_k_pe = (1/fov,1/fov) if isinstance(fov,float) else (1/fov[0], 1/fov[1])
+    kWidth_ro = Nf * delta_k_ro
+    flip = fa * pi / 180
+
+    # Slice select: RF and gradient
+    rf, g_ss, __ = make_sinc_pulse(flip_angle=flip,system=system,duration=4e-3,slice_thickness=thk,
+                               apodization=0.5, time_bw_product=4)
+    g_ss_x, g_ss_y, g_ss_z = make_oblique_gradients(g_ss,ug_ss)
+
+    # Readout and ADC
+#    readoutTime = 6.4e-3
+    dwell = 10e-6
+    g_ro= make_trapezoid(channel='x',system=system,flat_area=kWidth_ro, flat_time=dwell*Nf)
+    g_ro_x, g_ro_y, g_ro_z = make_oblique_gradients(g_ro,ug_fe)#
+    adc = make_adc(num_samples=Nf, duration=g_ro.flat_time,delay=g_ro.rise_time)
+
+    # Readout rewinder
+    g_ro_pre = make_trapezoid(channel='x',system=system,area=-g_ro.area/2,duration=2e-3)
+    g_ro_pre_x, g_ro_pre_y, g_ro_pre_z = make_oblique_gradients(g_ro_pre,ug_fe)#
+
+    # Slice refocusing
+    g_ss_reph = make_trapezoid(channel='z',system=system,area=-g_ss.area/2,duration=2e-3)
+    g_ss_reph_x, g_ss_reph_y, g_ss_reph_z = make_oblique_gradients(g_ss_reph, ug_ss)
+
+    # Prepare phase areas
+    phase_areas = (np.arange(Np) - (Np / 2)) * delta_k_pe
+
+    TE, TR = te,tr
+    delayTE = TE - calc_duration(g_ro_pre) - calc_duration(g_ss) / 2 - calc_duration(g_ro) / 2
+    delayTR = TR - calc_duration(g_ro_pre) - calc_duration(g_ss) - calc_duration(g_ro) - delayTE
+    delay1 = make_delay(delayTE)
+    delay2 = make_delay(delayTR)
+
+    if slice_locs is None:
+        locs = [0]
+    else:
+        locs = slice_locs
+
+    # Construct sequence!
+    for u in range(len(locs)):
+        # add frequency offset
+        rf.freq_offset = g_ss.amplitude * locs[u]
+        for i in range(Np):
+            seq.add_block(rf,g_ss_x, g_ss_y, g_ss_z)
+            g_pe = make_trapezoid(channel='y',system=system,area=phase_areas[i],duration=2e-3)
+
+            g_pe_x, g_pe_y, g_pe_z = make_oblique_gradients(g_pe,ug_pe)
+
+            pre_grads_list = [g_ro_pre_x, g_ro_pre_y, g_ro_pre_z,
+                             g_ss_reph_x, g_ss_reph_y, g_ss_reph_z,
+                             g_pe_x, g_pe_y, g_pe_z]
+
+            gtx, gty, gtz = combine_trap_grad_xyz(gradients=pre_grads_list,system=system, dur=2e-3)
+
+            seq.add_block(gtx, gty, gtz)
+            seq.add_block(delay1)
+            seq.add_block(g_ro_x, g_ro_y, g_ro_z, adc)
+            seq.add_block(delay2)
+
+    if write:
+        seq.write("gre_fov{:.0f}mm_Nf{:d}_Np{:d}_TE{:.0f}ms_TR{:.0f}ms_FA{:.0f}deg.seq".format(fov * 1000, Nf, Np, TE * 1000,
+                                                                                               TR * 1000, flip * 180 / pi))
+    print('GRE sequence constructed')
+    return seq
+
+
+def make_pulseq_irse(fov,n,thk,fa,tr,te,ti,enc='xyz',slice_locs=None,write=False):
+    """Makes an Inversion Recovery Spin Echo (IRSE) sequence
+
+        2D orthogonal multi-slice IRSE pulse sequence with Cartesian encoding
+        Orthogonal means that each of slice-selection, phase encoding, and frequency encoding
+        aligns with the x, y, or z directions
+
+        Parameters
+        ----------
+        fov : float
+            Field-of-view in meters (isotropic)
+        n : int
+            Matrix size (isotropic)
+        thk : float
+            Slice thickness in meters
+        fa : float
+            Flip angle in degrees
+        tr : float
+            Repetition time in seconds
+        te : float
+            Echo time in seconds
+        ti : float
+            Inversion time in seconds
+        enc : str, optional
+            Spatial encoding directions
+            1st - readout; 2nd - phase encoding; 3rd - slice select
+            Default 'xyz' means axial(z) slice with readout in x and phase encoding in y
+        slice_locs : array_like, optional
+            Slice locations from isocenter in meters
+            Default is None which means a single slice at the center
+        write : bool, optional
+            Whether to write seq into file; default is False
+
+        Returns
+        -------
+        seq : Sequence
+            Pulse sequence as a Pulseq object
+
+        """
+    system = Opts(max_grad=32, grad_unit='mT/m', max_slew=130,
+                  slew_unit='T/m/s', rf_ringdown_time=30e-6,
+                  rf_dead_time=100e-6, adc_dead_time=20e-6)
+    seq = Sequence(system)
+
+    # Parameters
+    Nf = n
+    Np = n
+    delta_k = 1 / fov
+    kWidth = Nf * delta_k
+
+    TI,TE,TR = ti,te,tr
+
+    if np.shape(TI) == ():
+        TI = [TI]
+
+
+    # Non-180 pulse
+    flip1 = fa * pi / 180
+    rf, g_ss, __ = make_sinc_pulse(flip_angle=flip1, system=system, duration=2e-3, slice_thickness=thk,
+                               apodization=0.5, time_bw_product=4)
+
+    g_ss.channel = enc[2]
+
+    # 180 pulse
+    flip2 = 180 * pi / 180
+    rf180, g_ss180, __ = make_sinc_pulse(flip_angle=flip2, system=system,duration=2e-3, slice_thickness=thk,
+                                     apodization=0.5, time_bw_product=4)
+    g_ss180.channel = enc[2]
+
+    # Readout gradient & ADC
+    readoutTime = 6.4e-3
+
+    g_ro = make_trapezoid(channel=enc[0],system=system, flat_area=kWidth, flat_time=readoutTime)
+    adc = make_adc(num_samples=Nf, system=system, duration=g_ro.flat_time, delay=g_ro.rise_time)
+
+    # RO rewinder gradient
+    g_ro_pre = make_trapezoid(channel=enc[0],system=system,area=g_ro.area/2,duration=2e-3)
+
+    # Slice refocusing gradient
+    g_ss_reph = make_trapezoid(channel=enc[2],system=system,area=-g_ss.area/2,duration=2e-3)
+
+    # Delays
+
+    delayTE1 = TE / 2 - max(calc_duration(g_ss_reph), calc_duration(g_ro_pre)) - calc_duration(g_ss) / 2 - calc_duration(
+        g_ss180) / 2
+    delayTE2 = TE / 2 - calc_duration(g_ro) / 2 - calc_duration(g_ss180) / 2
+    delayTE3 = TR - TE - calc_duration(g_ss) / 2 - calc_duration(g_ro) / 2
+
+    print('dur rf', calc_duration(rf),'dur gss:' ,calc_duration(g_ss))
+
+    delay1 = make_delay(delayTE1)
+    delay2 = make_delay(delayTE2)
+    delay3 = make_delay(delayTE3)
+
+    # Construct sequence
+    if slice_locs is None:
+        locs = [0]
+    else:
+        locs = slice_locs
+
+    for inv in range(len(TI)):
+        for u in range(len(locs)):
+            rf180.freq_offset = g_ss180.amplitude * locs[u]
+            rf.freq_offset = g_ss.amplitude * locs[u]
+            for i in range(Np):
+                # Inversion Recovery part
+                seq.add_block(rf180, g_ss180)# Selective; potentially extended to be non-selective or adiabatic
+                seq.add_block(make_delay(TI[inv] - calc_duration(rf) / 2 - calc_duration(rf180) / 2))  # Inversion time delay
+                # Spin echo part
+                seq.add_block(rf, g_ss)  # 90-deg pulse
+                g_pe_pre = make_trapezoid(channel=enc[1],system=system,area=-(Np/2-i)*delta_k,
+                                          duration=2e-3)  # Phase encoding gradient
+                seq.add_block(g_ro_pre, g_pe_pre, g_ss_reph)  # Add a combination of ro rewinder, phase encoding, and slice refocusing
+                seq.add_block(delay1)  # Delay 1: until 180-deg pulse
+                seq.add_block(rf180, g_ss180)  # 180 deg pulse for SE
+                seq.add_block(delay2)  # Delay 2: until readout
+                seq.add_block(g_ro, adc)  # Readout!
+                seq.add_block(delay3)  # Delay 3: until next inversion pulse
+
+    if write:
+        if len(TI) == 1:
+            seq.write("irse_fov{:.0f}mm_Nf{:d}_Np{:d}_TI{:.0f}ms_TE{:.0f}ms_TR{:.0f}ms.seq".format(fov * 1000, Nf, Np, TI[0] * 1000, TE * 1000, TR * 1000))
+        else:
+            seq.write("irse_fov{:.0f}mm_Nf{:d}_Np{:d}_multiTI_TE{:.0f}ms_TR{:.0f}ms.seq".format(fov * 1000, Nf, Np, TE * 1000, TR * 1000))
+
+    print('IRSE sequence constructed')
+    return seq
+
+def make_pulseq_irse_oblique(fov,n,thk,fa,tr,te,ti,enc='xyz',slice_locs=None,write=False):
+    """Makes an Inversion Recovery Spin Echo (IRSE) sequence in any plane
+
+        2D oblique multi-slice IRSE pulse sequence with Cartesian encoding
+        Oblique means that each of slice-selection, phase encoding, and frequency encoding
+        can point in any specified direction
+
+        Parameters
+        ----------
+        fov : array_like
+            Isotropic field-of-view, or length-2 list [fov_readout, fov_phase], in meters
+        n : array_like
+            Isotropic matrix size, or length-2 list [n_readout, n_phase]
+        thk : float
+            Slice thickness in meters
+        fa : float
+            Flip angle in degrees
+        tr : float
+            Repetition time in seconds
+        te : float
+            Echo time in seconds
+        ti : float
+            Inversion time in seconds
+        enc : str or array_like, optional
+            Spatial encoding directions
+            1st - readout; 2nd - phase encoding; 3rd - slice select
+            - Use str with any permutation of x, y, and z to obtain orthogonal slices
+            e.g. The default 'xyz' means axial(z) slice with readout in x and phase encoding in y
+            - Use list to indicate vectors in the encoding directions for oblique slices
+            They should be perpendicular to each other, but not necessarily unit vectors
+            e.g. [(2,1,0),(-1,2,0),(0,0,1)] rotates the two in-plane encoding directions for an axial slice
+        slice_locs : array_like, optional
+            Slice locations from isocenter in meters
+            Default is None which means a single slice at the center
+        write : bool, optional
+            Whether to write seq into file; default is False
+
+
+        Returns
+        -------
+        seq : Sequence
+            Pulse sequence as a Pulseq object
+
+        """
+    # System options
+    system = Opts(max_grad=32, grad_unit='mT/m', max_slew=130,
+                  slew_unit='T/m/s', rf_ringdown_time=30e-6,
+                  rf_dead_time=100e-6, adc_dead_time=20e-6)
+    seq = Sequence(system)
+
+    # Sequence parameters
+    ug_fe, ug_pe, ug_ss = parse_enc(enc)
+    Nf, Np = (n,n) if isinstance(n,int) else (n[0], n[1])
+    delta_k_ro, delta_k_pe = (1/fov,1/fov) if isinstance(fov,float) else (1/fov[0], 1/fov[1])
+    kWidth_ro = Nf * delta_k_ro
+    TI,TE,TR = ti,te,tr
+
+    if np.shape(TI) == ():
+        TI = [TI]
+
+    # Non-180 pulse
+    flip1 = fa * pi / 180
+    rf, g_ss, __ = make_sinc_pulse(flip_angle=flip1, system=system, duration=2e-3, slice_thickness=thk,
+                                apodization=0.5, time_bw_product=4)
+    g_ss_x, g_ss_y, g_ss_z = make_oblique_gradients(g_ss, ug_ss)
+
+    # 180 pulse
+    flip2 = 180 * pi / 180
+    rf180, g_ss180, __ = make_sinc_pulse(flip_angle=flip2, system=system, duration=2e-3, slice_thickness=thk,
+                                    apodization=0.5, time_bw_product=4)
+    g_ss180_x, g_ss180_y, g_ss180_z = make_oblique_gradients(g_ss180, ug_ss)
+
+    # Readout gradient & ADC
+    readoutTime = 6.4e-3
+
+    g_ro = make_trapezoid(channel='x', system=system, flat_area=kWidth_ro, flat_time= readoutTime)
+    g_ro_x, g_ro_y, g_ro_z = make_oblique_gradients(g_ro, ug_fe)
+
+    adc = make_adc(num_samples=Nf, system=system, duration=g_ro.flat_time, delay=g_ro.rise_time)
+
+    # RO rewinder gradient
+    g_ro_pre = make_trapezoid(channel=enc[0], system=system, area=g_ro.area/2,duration=2e-3)
+    g_ro_pre_x, g_ro_pre_y, g_ro_pre_z = make_oblique_gradients(g_ro_pre,ug_fe)#
+
+    # Slice refocusing gradient
+    g_ss_reph = make_trapezoid(channel=enc[2],system=system,area=-g_ss.area/2,duration=2e-3)
+    g_ss_reph_x, g_ss_reph_y, g_ss_reph_z = make_oblique_gradients(g_ss_reph, ug_ss)
+
+    # Delays
+    delayTE1 = TE / 2 - max(calc_duration(g_ss_reph), calc_duration(g_ro_pre))\
+               - calc_duration(g_ss) / 2 - calc_duration(g_ss180) / 2
+    delayTE2 = TE / 2 - calc_duration(g_ro) / 2 - calc_duration(g_ss180) / 2
+    delayTE3 = TR - TE - calc_duration(g_ss) / 2 - calc_duration(g_ro) / 2
+
+    delay1 = make_delay(delayTE1)
+    delay2 = make_delay(delayTE2)
+    delay3 = make_delay(delayTE3)
+
+    # Construct sequence
+    if slice_locs is None:
+        locs = [0]
+    else:
+        locs = slice_locs
+
+    for inv in range(len(TI)):
+        for u in range(len(locs)):
+            rf180.freq_offset = g_ss180.amplitude * locs[u]
+            rf.freq_offset = g_ss.amplitude * locs[u]
+            for i in range(Np):
+                # Inversion Recovery part
+                seq.add_block(rf180, g_ss180_x, g_ss180_y, g_ss180_z)# Non-selective at the moment; could be extended to make this selective/adiabatic
+                seq.add_block(make_delay(TI[inv] - calc_duration(rf) / 2 - calc_duration(rf180) / 2))  # Inversion time delay
+                # Spin echo part
+                seq.add_block(rf, g_ss_x, g_ss_y, g_ss_z)  # 90-deg pulse
+                g_pe = make_trapezoid(channel='y', system=system, area=-(Np /2 - i)*delta_k_pe, duration=2e-3)  # Phase encoding gradient
+                g_pe_x, g_pe_y, g_pe_z = make_oblique_gradients(g_pe, ug_pe)
+
+                pre_grads_list = [g_ro_pre_x, g_ro_pre_y, g_ro_pre_z,
+                                  g_ss_reph_x, g_ss_reph_y, g_ss_reph_z,
+                                  g_pe_x, g_pe_y, g_pe_z]
+                gtx, gty, gtz = combine_trap_grad_xyz(pre_grads_list,system,2e-3)
+
+                seq.add_block(gtx, gty, gtz)  # Add a combination of ro rewinder, phase encoding, and slice refocusing
+                seq.add_block(delay1)  # Delay 1: until 180-deg pulse
+                seq.add_block(rf180, g_ss180_x, g_ss180_y, g_ss180_z)  # 180 deg pulse for SE
+                seq.add_block(delay2)  # Delay 2: until readout
+                seq.add_block(g_ro_x, g_ro_y, g_ro_z, adc)  # Readout!
+                seq.add_block(delay3)  # Delay 3: until next inversion pulse
+
+    if write:
+        if len(TI) == 1:
+            seq.write("irse_fov{:.0f}mm_Nf{:d}_Np{:d}_TI{:.0f}ms_TE{:.0f}ms_TR{:.0f}ms_FA{:d}deg.seq".format(fov * 1000,
+                                                                            Nf, Np, TI[0] * 1000, TE * 1000, TR * 1000, fa))
+        else:
+            seq.write("irse_fov{:.0f}mm_Nf{:d}_Np{:d}_multiTI_TE{:.0f}ms_TR{:.0f}ms_FA{:d}deg.seq".format(fov * 1000,
+                                                                            Nf, Np, TE * 1000, TR * 1000, fa))
+
+    print('IRSE (oblique) sequence constructed')
+    return seq
+
+def make_pulseq_se(fov,n,thk,fa,tr,te,enc='xyz',slice_locs=None,write=False):
+    """Makes a Spin Echo (SE) sequence
+
+    2D orthogonal multi-slice Spin-Echo pulse sequence with Cartesian encoding
+    Orthogonal means that each of slice-selection, phase encoding, and frequency encoding
+    aligns with the x, y, or z directions
+
+    Parameters
+    ----------
+    fov : float
+        Field-of-view in meters (isotropic)
+    n : int
+        Matrix size (isotropic)
+    thk : float
+        Slice thickness in meters
+    fa : float
+        Flip angle in degrees
+    tr : float
+        Repetition time in seconds
+    te : float
+        Echo time in seconds
+    enc : str, optional
+        Spatial encoding directions
+        1st - readout; 2nd - phase encoding; 3rd - slice select
+        Default 'xyz' means axial(z) slice with readout in x and phase encoding in y
+    slice_locs : array_like, optional
+        Slice locations from isocenter in meters
+        Default is None which means a single slice at the center
+    write : bool, optional
+        Whether to write seq into file; default is False
+
+    Returns
+    -------
+    seq : Sequence
+        Pulse sequence as a Pulseq object
+
+    """
+    system = Opts(max_grad=32, grad_unit='mT/m', max_slew=130,
+                  slew_unit='T/m/s', rf_ringdown_time=30e-6,
+                  rf_dead_time=100e-6, adc_dead_time=20e-6)
+    seq = Sequence(system)
+
+    # Parameters
+    Nf = n
+    Np = n
+    delta_k = 1 / fov
+    kWidth = Nf * delta_k
+
+    TE,TR = te,tr
+
+
+    # Non-180 pulse
+    flip1 = fa * pi / 180
+    rf, g_ss, __ = make_sinc_pulse(flip_angle=flip1, system=system, duration=2e-3, slice_thickness=thk,
+                                apodization=0.5, time_bw_product=4)
+    g_ss.channel = enc[2]
+
+
+    # 180 pulse
+    flip2 = 180 * pi / 180
+    rf180, g_ss180, __ = make_sinc_pulse(flip_angle=flip2, system=system, duration=2e-3, slice_thickness=thk,
+                                    apodization=0.5, time_bw_product=4)
+    g_ss180.channel = enc[2]
+
+    # Readout gradient & ADC
+#    readoutTime = system.grad_raster_time * Nf
+    readoutTime = 6.4e-3
+    g_ro = make_trapezoid(channel=enc[0],system=system,flat_area=kWidth,flat_time=readoutTime)
+    adc = make_adc(num_samples=Nf, system=system, duration=g_ro.flat_time, delay=g_ro.rise_time)
+
+    # RO rewinder gradient
+    g_ro_pre = make_trapezoid(channel=enc[0],system=system,area=g_ro.area/2,duration=2e-3)
+
+    # Slice refocusing gradient
+    g_ss_reph = make_trapezoid(channel=enc[2],system=system,area=-g_ss.area/2,duration=2e-3)
+
+    # Delays
+    delayTE1 = (TE - 2*max(calc_duration(g_ss_reph), calc_duration(g_ro_pre)) - calc_duration(g_ss) - calc_duration(
+        g_ss180))/2
+  # delayTE2 = TE / 2 - calc_duration(g_ro) / 2 - calc_duration(g_ss180) / 2
+    delayTE2 = (TE - calc_duration(g_ro) - calc_duration(g_ss180))/2
+    delayTE3 = TR - TE - (calc_duration(g_ss) + calc_duration(g_ro)) / 2
+
+
+    delay1 = make_delay(delayTE1)
+    delay2 = make_delay(delayTE2)
+    delay3 = make_delay(delayTE3)
+
+
+    # Construct sequence
+    if slice_locs is None:
+        locs = [0]
+    else:
+        locs = slice_locs
+
+    for u in range(len(locs)):
+        rf180.freq_offset = g_ss180.amplitude * locs[u]
+        rf.freq_offset = g_ss.amplitude * locs[u]
+        for i in range(Np):
+            seq.add_block(rf, g_ss)  # 90-deg pulse
+            g_pe_pre = make_trapezoid(channel=enc[1],system=system,area=-(Np/2-i)*delta_k,duration=2e-3)  # Phase encoding gradient
+            seq.add_block(g_ro_pre, g_pe_pre, g_ss_reph)  # Add a combination of ro rewinder, phase encoding, and slice refocusing
+            seq.add_block(delay1)  # Delay 1: until 180-deg pulse
+            seq.add_block(rf180, g_ss180)  # 180 deg pulse for SE
+            seq.add_block(delay2)  # Delay 2: until readout
+            seq.add_block(g_ro, adc)  # Readout!
+            seq.add_block(delay3)  # Delay 3: until next inversion pulse
+
+    if write:
+        seq.write("se_fov{:.0f}mm_Nf{:d}_Np{:d}_TE{:.0f}ms_TR{:.0f}ms.seq".format(fov * 1000, Nf, Np, TE * 1000, TR * 1000))
+
+
+    print('Spin echo sequence constructed')
+    return seq
+
+def make_pulseq_se_oblique(fov,n,thk,fa,tr,te,enc='xyz',slice_locs=None,write=False):
+    """Makes a Spin Echo (SE) sequence in any plane
+
+        2D oblique multi-slice Spin-Echo pulse sequence with Cartesian encoding
+        Oblique means that each of slice-selection, phase encoding, and frequency encoding
+        can point in any specified direction
+
+        Parameters
+        ----------
+        fov : array_like
+            Isotropic field-of-view, or length-2 list [fov_readout, fov_phase], in meters
+        n : array_like
+            Isotropic matrix size, or length-2 list [n_readout, n_phase]
+        thk : float
+            Slice thickness in meters
+        fa : float
+            Flip angle in degrees
+        tr : float
+            Repetition time in seconds
+        te : float
+            Echo time in seconds
+        enc : str or array_like, optional
+            Spatial encoding directions
+            1st - readout; 2nd - phase encoding; 3rd - slice select
+            - Use str with any permutation of x, y, and z to obtain orthogonal slices
+            e.g. The default 'xyz' means axial(z) slice with readout in x and phase encoding in y
+            - Use list to indicate vectors in the encoding directions for oblique slices
+            They should be perpendicular to each other, but not necessarily unit vectors
+            e.g. [(2,1,0),(-1,2,0),(0,0,1)] rotates the two in-plane encoding directions for an axial slice
+        slice_locs : array_like, optional
+            Slice locations from isocenter in meters
+            Default is None which means a single slice at the center
+        write : bool, optional
+            Whether to write seq into file; default is False
+
+        Returns
+        -------
+        seq : Sequence
+            Pulse sequence as a Pulseq object
+
+        """
+
+    # System options
+    system = Opts(max_grad=32, grad_unit='mT/m', max_slew=130,
+                  slew_unit='T/m/s', rf_ringdown_time=30e-6,
+                  rf_dead_time=100e-6, adc_dead_time=20e-6)
+    seq = Sequence(system)
+
+    # Sequence parameters
+    ug_fe, ug_pe, ug_ss = parse_enc(enc)
+    Nf, Np = (n,n) if isinstance(n,int) else (n[0], n[1])
+    delta_k_ro, delta_k_pe = (1/fov,1/fov) if isinstance(fov,float) else (1/fov[0], 1/fov[1])
+    kWidth_ro = Nf * delta_k_ro
+    TE,TR = te,tr
+
+    # Non-180 pulse
+    flip1 = fa * pi / 180
+    rf, g_ss, __ = make_sinc_pulse(flip_angle=flip1, system=system, duration=2e-3, slice_thickness=thk,
+                                apodization=0.5, time_bw_product=4)
+    g_ss_x, g_ss_y, g_ss_z = make_oblique_gradients(g_ss,ug_ss)
+
+    # 180 pulse
+    flip2 = 180 * pi / 180
+    rf180, g_ss180, __ = make_sinc_pulse(flip_angle=flip2, system=system, duration=2e-3, slice_thickness=thk,
+                                     apodization=0.5, time_bw_product=4)
+    g_ss180_x, g_ss180_y, g_ss180_z = make_oblique_gradients(g_ss180,ug_ss)
+
+    # Readout gradient & ADC
+    readoutTime = 6.4e-3
+    g_ro = make_trapezoid(channel='x',system=system,flat_area=kWidth_ro, flat_time=readoutTime)
+    g_ro_x, g_ro_y, g_ro_z = make_oblique_gradients(g_ro, ug_fe)
+    adc = make_adc(num_samples=Nf, system=system, duration=g_ro.flat_time, delay=g_ro.rise_time)
+
+    # RO rewinder gradient
+    g_ro_pre = make_trapezoid(channel='x',system=system,area=g_ro.area/2,duration=2e-3)
+    g_ro_pre_x, g_ro_pre_y, g_ro_pre_z = make_oblique_gradients(g_ro_pre, ug_fe)
+
+    # Slice refocusing gradient
+    g_ss_reph = make_trapezoid(channel='z',system=system,area=-g_ss.area/2,duration=2e-3)
+    g_ss_reph_x, g_ss_reph_y, g_ss_reph_z = make_oblique_gradients(g_ss_reph, ug_ss)
+
+    # Delays
+    delayTE1 = (TE - 2*max(calc_duration(g_ss_reph), calc_duration(g_ro_pre)) - calc_duration(g_ss) - calc_duration(
+        g_ss180))/2
+    delayTE2 = (TE - calc_duration(g_ro) - calc_duration(g_ss180))/2
+    delayTE3 = TR - TE - (calc_duration(g_ss) + calc_duration(g_ro)) / 2
+
+
+    delay1 = make_delay(delayTE1)
+    delay2 = make_delay(delayTE2)
+    delay3 = make_delay(delayTE3)
+
+    # Construct sequence
+    if slice_locs is None:
+        locs = [0]
+    else:
+        locs = slice_locs
+
+    for u in range(len(locs)):
+        rf180.freq_offset = g_ss180.amplitude * locs[u]
+        rf.freq_offset = g_ss.amplitude * locs[u]
+        for i in range(Np):
+            seq.add_block(rf, g_ss_x, g_ss_y, g_ss_z)  # 90-deg pulse
+            g_pe = make_trapezoid(channel='y',system=system,area=-(Np/2 - i)*delta_k_pe, duration=2e-3)  # Phase encoding gradient
+            g_pe_x, g_pe_y, g_pe_z = make_oblique_gradients(g_pe, ug_pe)
+
+            pre_grads_list = [g_ro_pre_x, g_ro_pre_y, g_ro_pre_z,
+                              g_ss_reph_x, g_ss_reph_y, g_ss_reph_z,
+                              g_pe_x, g_pe_y, g_pe_z]
+            gtx, gty, gtz = combine_trap_grad_xyz(pre_grads_list, system, 2e-3)
+
+
+            seq.add_block(gtx,gty,gtz)  # Add a combination of ro rewinder, phase encoding, and slice refocusing
+            seq.add_block(delay1)  # Delay 1: until 180-deg pulse
+            seq.add_block(rf180, g_ss180_x, g_ss180_y, g_ss180_z)  # 180 deg pulse for SE
+            seq.add_block(delay2)  # Delay 2: until readout
+            seq.add_block(g_ro_x, g_ro_y, g_ro_z, adc)  # Readout!
+            seq.add_block(delay3)  # Delay 3: until next inversion pulse
+
+    if write:
+        seq.write("se_fov{:.0f}mm_Nf{:d}_Np{:d}_TE{:.0f}ms_TR{:.0f}ms_FA{:d}deg.seq".format(fov * 1000, Nf, Np, TE * 1000, TR * 1000, fa))
+
+
+    print('Spin echo sequence (oblique) constructed')
+    return seq
+
+
+# TODO multi-shot epi needs to be tested on scanner! : )
+def make_pulseq_epi_oblique(fov,n,thk,fa,tr,te,enc='xyz',slice_locs=None,echo_type="se",n_shots=1,seg_type='blocked',write=False):
+    """Makes an Echo Planar Imaging (EPI) sequence in any plane
+
+        2D oblique multi-slice EPI pulse sequence with Cartesian encoding
+        Oblique means that each of slice-selection, phase encoding, and frequency encoding
+        can point in any specified direction
+
+        Parameters
+        ----------
+        fov : array_like
+            Isotropic field-of-view, or length-2 list [fov_readout, fov_phase], in meters
+        n : array_like
+            Isotropic matrix size, or length-2 list [n_readout, n_phase]
+        thk : float
+            Slice thickness in meters
+        fa : float
+            Flip angle in degrees
+        tr : float
+            Repetition time in seconds
+        te : float
+            Echo time in seconds
+        enc : str or array_like, optional
+            Spatial encoding directions
+            1st - readout; 2nd - phase encoding; 3rd - slice select
+            - Use str with any permutation of x, y, and z to obtain orthogonal slices
+            e.g. The default 'xyz' means axial(z) slice with readout in x and phase encoding in y
+            - Use list to indicate vectors in the encoding directions for oblique slices
+            They should be perpendicular to each other, but not necessarily unit vectors
+            e.g. [(2,1,0),(-1,2,0),(0,0,1)] rotates the two in-plane encoding directions for an axial slice
+        slice_locs : array_like, optional
+            Slice locations from isocenter in meters
+            Default is None which means a single slice at the center
+        echo_type : str, optional {'se','gre'}
+            Type of echo generated
+            se (default) - spin echo (an 180 deg pulse is used)
+            gre - gradient echo
+        n_shots : int, optional
+            Number of shots used to encode each slicel; default is 1
+        seg_type : str, optional {'blocked','interleaved'}
+            Method to divide up k-space in the case of n_shots > 1; default is 'blocked'
+            'blocked' - each shot covers a rectangle, with no overlap between shots
+            'interleaved' - each shot samples the full k-space but with wider phase steps
+
+        write : bool, optional
+            Whether to write seq into file; default is False
+
+        Returns
+        -------
+        seq : Sequence
+            Pulse sequence as a Pulseq object
+        ro_dirs : numpy.ndarray
+            List of 0s and 1s indicating direction of readout
+            0 - left to right
+            1 - right to left (needs to be reversed at recon)
+        ro_order : numpy.ndarray
+            Order in which to re-arrange the readout lines
+            It is [] for blocked acquisition (retain original order)
+
+        """
+    # Multi-slice, multi-shot (>=1)
+    # TE is set to be where the trajectory crosses the center of k-space
+
+    # System options
+    system = Opts(max_grad=32, grad_unit='mT/m', max_slew=130,
+                  slew_unit='T/m/s', rf_ringdown_time=30e-6,
+                  rf_dead_time=100e-6, adc_dead_time=20e-6)
+    seq = Sequence(system)
+
+
+    ug_fe, ug_pe, ug_ss = parse_enc(enc)
+
+    # Sequence parameters
+    Nf, Np = (n,n) if isinstance(n,int) else (n[0], n[1])
+    delta_k_ro, delta_k_pe = (1/fov,1/fov) if isinstance(fov,float) else (1/fov[0], 1/fov[1])
+    kWidth_ro = Nf * delta_k_ro
+    TE,TR = te,tr
+    flip = fa * pi / 180
+
+    # RF Pulse (first)
+    rf, g_ss, __ = make_sinc_pulse(flip_angle=flip, system=system,duration=2.5e-3, slice_thickness=thk,
+                               apodization=0.5, time_bw_product=4)
+    g_ss_x, g_ss_y, g_ss_z = make_oblique_gradients(g_ss, ug_ss)
+
+
+    # Readout gradients
+#    readoutTime = Nf * 4e-6
+    dwell=1e-5
+    readoutTime = Nf*dwell
+    g_ro_pos = make_trapezoid(channel='x',system=system,flat_area=kWidth_ro,flat_time=readoutTime)
+    g_ro_pos_x, g_ro_pos_y, g_ro_pos_z = make_oblique_gradients(g_ro_pos,ug_fe)
+    g_ro_neg = copy.deepcopy(g_ro_pos)
+    modify_gradient(g_ro_neg,scale=-1)
+    g_ro_neg_x, g_ro_neg_y, g_ro_neg_z = make_oblique_gradients(g_ro_neg,ug_fe)
+
+    # TODO make sure delay is a multiple of gradient raster time
+#    adc = make_adc(num_samples=Nf, system=system, duration=g_ro_pos.flat_time, delay=g_ro_pos.rise_time+dwell/2)
+    adc = make_adc(num_samples=Nf, system=system, duration=g_ro_pos.flat_time, delay=g_ro_pos.rise_time)
+    print("ADC delay: ", adc.delay)
+
+    pre_time = 8e-4
+
+    # 180 deg pulse for SE
+    if echo_type == "se":
+        # RF Pulse (180 deg for SE)
+        flip180 = 180 * pi / 180
+        rf180, g_ss180, __ = make_sinc_pulse(flip_angle=flip180, system=system,duration=2.5e-3,slice_thickness=thk,
+                                         apodization=0.5, time_bw_product=4)
+        g_ss180_x, g_ss180_y, g_ss180_z = make_oblique_gradients(g_ss180, ug_ss)
+
+        # Slice-select direction spoilers
+        g_ss_spoil = make_trapezoid(channel='z',system=system,area=g_ss.area*2,duration=3*pre_time)
+        ##
+        modify_gradient(g_ss_spoil,0)
+        ##
+        g_ss_spoil_x, g_ss_spoil_y, g_ss_spoil_z = make_oblique_gradients(g_ss_spoil, ug_ss)
+
+    # Readout rewinder
+    ro_pre_area = g_ro_neg.area / 2 if echo_type == 'gre' else g_ro_pos.area / 2
+    g_ro_pre = make_trapezoid(channel='x',system=system, area=ro_pre_area, duration=pre_time)
+    g_ro_pre_x, g_ro_pre_y, g_ro_pre_z = make_oblique_gradients(g_ro_pre,ug_fe)
+
+    # Slice-selective rephasing
+    g_ss_reph = make_trapezoid(channel='z',system=system,area=-g_ss.area/2,duration=pre_time)
+    g_ss_reph_x, g_ss_reph_y, g_ss_reph_z = make_oblique_gradients(g_ss_reph, ug_ss)
+
+    # Phase encode rewinder
+    if echo_type == 'gre':
+        pe_max_area = (Np/2)*delta_k_pe
+    elif echo_type == 'se':
+        pe_max_area = -(Np/2)*delta_k_pe
+
+    g_pe_max = make_trapezoid(channel='y',system=system,area=pe_max_area,duration=pre_time)
+
+    # Phase encoding blips
+    dur = ceil(2 * sqrt(delta_k_pe/ system.max_slew) / 10e-6) * 10e-6
+    g_blip = make_trapezoid(channel='y',system=system,area=delta_k_pe,duration=dur)
+
+    # Delays
+    duration_to_center = (Np/ 2 ) * calc_duration(g_ro_pos) + (Np-1) / 2 * calc_duration(g_blip) # why?
+
+    if echo_type == 'se':
+        delayTE1 = TE / 2 - calc_duration(g_ss) / 2 - pre_time - calc_duration(g_ss_spoil) - calc_duration(rf180) / 2
+        delayTE2 = TE / 2 - calc_duration(rf180) / 2 - calc_duration(g_ss_spoil) - duration_to_center
+        delay1 = make_delay(delayTE1)
+        delay2 = make_delay(delayTE2)
+    elif echo_type == 'gre':
+        delayTE = TE - calc_duration(g_ss)/2 - pre_time - duration_to_center
+        delay12 = make_delay(delayTE)
+
+    delayTR = TR - TE - calc_duration(rf) / 2 - duration_to_center
+    delay3 = make_delay(delayTR) # This might be different for each rep though. Fix later
+
+#####################################################################################################
+    # Multi-shot calculations
+    ro_dirs = []
+    ro_order = []
+
+    # Find number of lines in each block
+
+    if seg_type == 'blocked':
+
+        # Number of lines in each full readout block
+        nl = ceil(Np / n_shots)
+
+        # Number of k-space lines per readout
+        if Np%nl == 0:
+            nlines_list = nl*np.ones(n_shots)
+        else:
+            nlines_list = nl*np.ones(n_shots-1)
+            nlines_list = np.append(nlines_list,Np%nl)
+
+        pe_scales = 2*np.append([0],np.cumsum(nlines_list)[:-1])/Np - 1
+        g_blip_x, g_blip_y, g_blip_z = make_oblique_gradients(g_blip, ug_pe)
+        for nlines in nlines_list:
+            ro_dirs = np.append(ro_dirs, ((-1)**(np.arange(0,nlines)+1)+1)/2)
+
+
+    elif seg_type == 'interleaved':
+        # Minimum number of lines per readout
+        nb = floor(Np / n_shots)
+
+        # Number of k-space lines per readout
+        nlines_list = np.ones(n_shots)*nb
+        nlines_list[:Np%n_shots] += 1
+
+        # Phase encoding scales (starts from -1; i.e. bottom left combined with pre-readout)
+        pe_scales = 2*np.arange(0,(Np-n_shots)/Np,1/Np)[0:n_shots]-1
+        print(pe_scales)
+        # Larger blips
+        modify_gradient(g_blip, scale=n_shots)
+        g_blip_x, g_blip_y, g_blip_z = make_oblique_gradients(g_blip, ug_pe)
+
+#        ro_order = np.reshape(np.reshape(np.arange(0,Np),(),order='F'),(0,Np))
+
+        ro_order = np.zeros((nb+1,n_shots))
+        ro_inds = np.arange(Np)
+        # Readout order for recon
+        for k in range(n_shots):
+            cs = int(nlines_list[k])
+            ro_order[:cs,k] = ro_inds[:cs]
+            ro_inds = np.delete(ro_inds,range(cs))
+        ro_order = ro_order.flatten()[:Np].astype(int)
+
+        np.save('readout_order_for_interleaving.npy', ro_order)
+
+        print(ro_order)
+
+        # Readout directions in original (interleaved) order
+        for nlines in nlines_list:
+            ro_dirs = np.append(ro_dirs, ((-1)**(np.arange(0,nlines)+1)+1)/2)
+
+
+
+#####################################################################################################
+
+    # Add blocks
+
+    for u in range(len(slice_locs)): # For each slice
+        # Offset rf
+        rf.freq_offset = g_ss.amplitude * slice_locs[u]
+        for v in range(n_shots):
+            # Find init. phase encode
+            g_pe = copy.deepcopy(g_pe_max)
+            modify_gradient(g_pe, pe_scales[v])
+            g_pe_x, g_pe_y, g_pe_z = make_oblique_gradients(g_pe, ug_pe)
+            # First RF
+            seq.add_block(rf, g_ss_x, g_ss_y, g_ss_z)
+            # Pre-winder gradients
+            pre_grads_list = [g_ro_pre_x, g_ro_pre_y, g_ro_pre_z,
+                              g_pe_x, g_pe_y, g_pe_z,
+                              g_ss_reph_x, g_ss_reph_y, g_ss_reph_z]
+            gtx, gty, gtz = combine_trap_grad_xyz(pre_grads_list, system, pre_time)
+            seq.add_block(gtx, gty, gtz)
+
+
+            # 180 deg pulse and spoilers, only for Spin Echo
+            if echo_type == 'se':
+                # First delay
+                seq.add_block(delay1)
+                # Second RF : 180 deg with spoilers on both sides
+                seq.add_block(g_ss_spoil_x, g_ss_spoil_y, g_ss_spoil_z)#why?
+                seq.add_block(rf180, g_ss180_x, g_ss180_y, g_ss180_z)
+                seq.add_block(g_ss_spoil_x, g_ss_spoil_y, g_ss_spoil_z)
+                # Delay between rf180 and beginning of readout
+                seq.add_block(delay2)
+            # For gradient echo it's just a delay
+            elif echo_type == 'gre':
+                seq.add_block(delay12)
+
+
+
+            # EPI readout with blips
+            for i in range(int(nlines_list[v])):
+                if i%2 == 0:
+                    seq.add_block(g_ro_pos_x, g_ro_pos_y, g_ro_pos_z, adc) # ro line in the positive direction
+                else:
+                    seq.add_block(g_ro_neg_x, g_ro_neg_y, g_ro_neg_z, adc) # ro line backwards
+                seq.add_block(g_blip_x, g_blip_y, g_blip_z) # blip
+
+            seq.add_block(delay3)
+
+    # Display 1 TR
+    #seq.plot(time_range=(0, TR))
+
+    if write:
+        seq.write("epi_{}_FOV{:.0f}mm_Nf{:d}_Np{:d}_TE{:.0f}ms_TR{:.0f}ms_FA{:d}deg_{:d}shots.seq"\
+                  .format(echo_type, fov*1000, Nf, Np, TE * 1000, TR * 1000, fa, n_shots))
+
+
+    print('EPI sequence (oblique) constructed')
+    return seq, ro_dirs, ro_order
+
+
+
+
+def parse_enc(enc):
+    """Helper function for decoding enc parameter
+
+    Parameters
+    ----------
+    enc : str or array_like
+        Inputted encoding scheme to parse
+    Returns
+    -------
+    ug_fe : numpy.ndarray
+        Length-3 vector of readout direction
+    ug_pe : numpy.ndarray
+        Length-3 vector of phase encoding direction
+    ug_ss : numpy.ndarray
+        Length-3 vector of slice selecting direction
+
+    """
+    if isinstance(enc, str):
+        xyz_dict = {'x': (1, 0, 0), 'y': (0, 1, 0), 'z': (0, 0, 1)}
+        ug_fe = xyz_dict[enc[0]]
+        ug_pe = xyz_dict[enc[1]]
+        ug_ss = xyz_dict[enc[2]]
+    else:
+        ug_fe = np.array(enc[0])
+        ug_pe = np.array(enc[1])
+        ug_ss = np.array(enc[2])
+
+        ug_fe = ug_fe / np.linalg.norm(ug_fe)
+        ug_pe = ug_pe / np.linalg.norm(ug_pe)
+        ug_ss = ug_ss / np.linalg.norm(ug_ss)
+
+    print('ug_fe: ', ug_fe)
+    print('ug_pe: ', ug_pe)
+    print('ug_ss: ', ug_ss)
+
+    return ug_fe, ug_pe, ug_ss
+
+
+def make_oblique_gradients(gradient,unit_grad):
+    """Helper function to make oblique gradients
+
+    (Gx, Gy, Gz) are generated from a single orthogonal gradient
+    and a direction indicated by unit vector
+
+    Parameters
+    ----------
+    gradient : Gradient
+        Pulseq gradient object
+    unit_grad: array_like
+        Length-3 unit vector indicating direction of resulting oblique gradient
+
+    Returns
+    -------
+    ngx, ngy, ngz : Gradient
+        Oblique gradients in x, y, and z directions
+
+    """
+    ngx = copy.deepcopy(gradient)
+    ngy = copy.deepcopy(gradient)
+    ngz = copy.deepcopy(gradient)
+
+    modify_gradient(ngx, unit_grad[0],'x')
+    modify_gradient(ngy, unit_grad[1],'y')
+    modify_gradient(ngz, unit_grad[2],'z')
+
+    return ngx, ngy, ngz
+
+def modify_gradient(gradient,scale,channel=None):
+    """Helper function to modify the strength and channel of an existing gradient
+
+    Parameters
+    ----------
+    gradient : Gradient
+        Pulseq gradient object to be modified
+    scale : float
+        Scalar to multiply the gradient strength by
+    channel : str, optional {None, 'x','y','z'}
+        Channel to switch gradient into
+        Default is None which keeps the original channel
+
+    """
+    gradient.amplitude *= scale
+    gradient.area *= scale
+    if gradient.type == 'trap':
+        gradient.flat_area *= scale
+    if channel != None:
+        gradient.channel = channel
+
+
+def combine_trap_grad_xyz(gradients,system,dur):
+    """Helper function that merges multiple gradients
+
+    A list of gradients are combined into one set of 3 oblique gradients (Gx, Gy, Gz) with equivalent areas
+    Note that the waveforms are not preserved : the outputs will always be trapezoidal gradients
+
+    Parameters
+    ----------
+    gradients : list
+        List of gradients to be combined; there can be any number of x, y, or z gradients
+    system : Opts
+        Pulseq object that indicates system constraints for gradient parameters
+    dur : float
+        Duration of the output oblique gradients
+
+    Returns
+    -------
+    gtx, gty, gtz : Gradient
+        Oblique pulseq gradients with equivalent areas to all input gradients combined
+
+    """
+    gx_area, gy_area, gz_area = (0,0,0)
+    for g in gradients:
+        if g.channel == 'x':
+            gx_area += g.area
+        elif g.channel == 'y':
+            gy_area += g.area
+        elif g.channel == 'z':
+            gz_area += g.area
+
+
+    gtx = make_trapezoid(channel='x',system=system,area=gx_area,duration=dur)
+    gty = make_trapezoid(channel='y',system=system,area=gy_area,duration=dur)
+    gtz = make_trapezoid(channel='z',system=system,area=gz_area,duration=dur)
+
+    return gtx, gty, gtz
+
+

File diff suppressed because it is too large
+ 2223 - 0
libs/lf-scanner/py2jemris/py2jemris_demo.ipynb


+ 180 - 0
libs/lf-scanner/py2jemris/recon_jemris.py

@@ -0,0 +1,180 @@
+# Converts the jemris simulation outputs  (signals.h5 files) into data or save as .npy or .mat files
+# Gehua Tong
+# March 06, 2020
+
+
+import h5py
+import numpy as np
+import matplotlib.pyplot as plt
+
+def recon_jemris(file, dims):
+    """Reads JEMRIS's signals.h5 output, reconstructs it (Cartesian only for now) using the dimensions specified,
+             and returns both the complex k-space and image matrix AND magnitude images
+
+    Inputs
+    ------
+    file : str
+        Path to signals.h5
+    dims : array_like
+        Dimensions for reconstruction
+        [Nro], [Nro, Nline], or [Nro, Nline, Nslice]
+
+    Returns
+    -------
+    kspace : np.ndarray
+        Complex k-space
+    imspace : np.ndarray
+        Complex image space
+    images : np.ndarray
+        Real, channel-combined images
+
+
+    """
+    Mxy_out, M_vec_out, times_out = read_jemris_output(file)
+    kspace, imspace = recon_jemris_output(Mxy_out, dims)
+    images = save_recon_images(imspace)# TODO save as png (use previous code!)
+
+    return kspace, imspace, images
+
+
+def read_jemris_output(file):
+    """Reads and parses JEMRIS's signals.h5 output
+
+    Inputs
+    ------
+    file : str
+        Path to signals.h5
+
+    Returns
+    -------
+    Mxy_out : np.ndarray
+        Complex representation of transverse magnetization sampled during readout
+        Matrix dimensions : (total # readouts) x (# channels)
+    M_vec_out : np.ndarray
+        3D representation of magnetization vector (Mx, My, Mz) sampled during readout
+        Matrix dimensions : (total # readouts) x 3 x (# channels)
+    times_out : np.ndarray
+        Timing vector for all readout points
+
+
+    """
+    # 1. Read simulated data
+    f = h5py.File(file,'r')
+    signal = f['signal']
+    channels = signal['channels']
+
+    # 2. Initialize output array
+    Nch = len(channels.keys())
+    Nro_tot = channels[list(channels.keys())[0]].shape[0]
+    M_vec_out = np.zeros((Nro_tot,3,Nch))
+    Mxy_out = np.zeros((Nro_tot,Nch), dtype=complex)
+    times_out = np.array(signal['times'])
+
+    # 3. Read each channel and store in array
+    for ch, key in enumerate(list(channels.keys())):
+        one_ch_data = np.array(channels[key])
+
+        M_vec_out[:,:,ch] = one_ch_data
+        Mxy_out[:,ch] = one_ch_data[:,0] + 1j*one_ch_data[:,1]
+
+
+    return Mxy_out, M_vec_out, times_out
+
+
+def recon_jemris_output(Mxy_out, dims):
+    """Cartesian reconstruction of JEMRIS simulation output
+    #  (No EPI/interleave reordering)
+
+    Inputs
+    ------
+    Mxy_out : np.ndarray
+        Complex Nt x Nch array where Nt is the total number of data points and Nch is the number of channels
+
+    dims : array_like
+        [Nro], [Nro, Nline], or [Nro, Nline, Nslice]
+
+
+    Returns
+    -------
+    kspace : np.ndarray
+        Complex k-space matrix
+    imspace : np.ndarray
+        Complex image space matrix
+
+    """
+    Nt, Nch = Mxy_out.shape
+    print(Nt)
+    if Nt != np.prod(dims):
+        raise ValueError("The dimensions provided do not match the total number of samples.")
+    Nro = dims[0]
+    Nline = 1
+    Nslice = 1
+
+    ld = len(dims)
+
+    if ld >= 1:
+        Nro = dims[0]
+    if ld >= 2:
+        Nline = dims[1]
+    if ld == 3:
+        Nslice = dims[2]
+    if ld > 3:
+        raise ValueError("dims should have at 1-3 numbers : Nro, (Nline), and (Nslice)")
+
+    kspace = np.zeros((Nro, Nline, Nslice, Nch),dtype=complex)
+    imspace = np.zeros((Nro, Nline, Nslice, Nch),dtype=complex)
+
+    np.reshape(Mxy_out, (Nro, Nline, Nslice))
+
+    for ch in range(Nch):
+        kspace[:,:,:,ch] = np.reshape(Mxy_out[:, ch], (Nro, Nline, Nslice), order='F')
+        for sl in range(Nslice):
+                imspace[:,:,sl,ch] = np.fft.fftshift(np.fft.ifft2(kspace[:,:,sl,ch]))
+
+    return kspace, imspace
+
+
+def save_recon_images(imspace, method='sum_squares'):
+    """For now, this method combines channels and returns the image matrix
+       (Future, for GUI use: add options to save as separate image files / mat / etc. in a directory)
+
+    Inputs
+    ------
+    imspace : np.ndarray
+        Complex image space. The last dimension must be # Channels.
+    method : str, optional
+        Method used for combining channels
+        Either 'sum_squares' (default, sum of squares) or 'sum_abs' (sum of absolute values)
+
+    Returns
+    -------
+    images : np.ndarray
+        Real, channel_combined image matrix
+
+    """
+    if method == 'sum_squares':
+        images = np.sum(np.square(np.absolute(imspace)),axis=-1)
+    elif method == 'sum_abs':
+        images = np.sum(np.absolute(imspace), axis=-1)
+    else:
+        raise ValueError("Method not recognized. Must be either sum_squares or sum_abs")
+    return images
+
+
+if __name__ == '__main__':
+    Mxy_out, M_vec_out, times_out = read_jemris_output('sim/test0405/signals.h5')
+    kk, im = recon_jemris_output(Mxy_out, dims=[15,15])
+    images = save_recon_images(im)
+
+
+    plt.figure(1)
+    plt.subplot(121)
+    plt.imshow(np.absolute(kk[:,:,0,0]))
+    plt.title("k-space")
+    plt.gray()
+    plt.subplot(122)
+    print(images)
+    plt.imshow(np.squeeze(images[:,:,0]))
+    plt.title("Image space")
+    plt.gray()
+    plt.show()

+ 42 - 0
libs/lf-scanner/py2jemris/record_seq2xml_times.py

@@ -0,0 +1,42 @@
+# Records how long seq2xml takes to convert SPGR sequences at N = 16, 32, 64, and 128
+
+from seq2xml import seq2xml
+import timeit
+from pypulseq.Sequence.sequence import Sequence
+from scipy.io import savemat, loadmat
+import numpy as np
+from datetime import datetime
+from pulseq_jemris_simulator import simulate_pulseq_jemris, recon_jemris
+
+def tbt_seq2xml(n):
+    seq = Sequence()
+    seq_path = f'sim/ismrm_abstract/spgr_var_N/spgr_gspoil_N{n}_Ns1_TE10ms_TR50ms_FA30deg_acq_112020.seq'
+    seq.read(seq_path)
+    seq2xml(seq, seq_name=f'spgr{n}',out_folder=f'sim/ismrm_abstract/spgr_var_N/spgr{n}')
+
+def tbt_sim_pipeline(n):
+    # SPGR
+    phantom_info = {'fov': 0.25, 'N': n, 'type': 'cylindrical', 'dim': 2, 'dir': 'z', 'loc': 0}
+    sps = f'sim/ismrm_abstract/spgr_var_N/spgr_gspoil_N{n}_Ns1_TE10ms_TR50ms_FA30deg_acq_112020.seq'
+    sim_name = f'ismrm_abstract\\spgr_var_N\\spgr{n}'
+
+    # Use cylindrical phantom to time
+    # Simulate
+    simulate_pulseq_jemris(seq_path=sps, phantom_info=phantom_info, sim_name=sim_name, coil_fov=0.25)
+    kk, im, images = recon_jemris(file='sim/' + sim_name + '/signals.h5', dims=[n, n])
+    savemat('sim/' + sim_name + '/utest_pulseq_sim_output.mat', {'images': images, 'kspace': kk, 'imspace': im})
+
+
+if __name__ == '__main__':
+    #for n in [8]:
+    #    print(f'Timing seq2xml for n = {n}')
+    #    ttken = timeit.timeit('tbt_seq2xml(N)', setup=f'N = {n}; from __main__ import tbt_seq2xml',number=10)
+    #    print(f'Avg. time over 10 reps is {ttken}')
+
+
+
+    #Time pipeline
+    q = 1
+    n = 8
+    ttken = timeit.timeit('tbt_sim_pipeline(n)',setup=f'n={n}; from __main__ import tbt_sim_pipeline',number=q)
+    print(f'Avg. sim pipeline time over {q} reps for n = {n} is {ttken} seconds.')

+ 5 - 0
libs/lf-scanner/py2jemris/requirements.txt

@@ -0,0 +1,5 @@
+h5py~=2.10.0
+matplotlib~=3.3.1
+numpy~=1.19.1
+scipy~=1.5.2
+pypulseq~=1.2.0.post3

BIN
libs/lf-scanner/py2jemris/rf_1.h5


BIN
libs/lf-scanner/py2jemris/rf_10.h5


BIN
libs/lf-scanner/py2jemris/rf_11.h5


BIN
libs/lf-scanner/py2jemris/rf_12.h5


BIN
libs/lf-scanner/py2jemris/rf_13.h5


BIN
libs/lf-scanner/py2jemris/rf_14.h5


BIN
libs/lf-scanner/py2jemris/rf_15.h5


BIN
libs/lf-scanner/py2jemris/rf_16.h5


BIN
libs/lf-scanner/py2jemris/rf_17.h5


BIN
libs/lf-scanner/py2jemris/rf_18.h5


BIN
libs/lf-scanner/py2jemris/rf_19.h5


BIN
libs/lf-scanner/py2jemris/rf_2.h5


BIN
libs/lf-scanner/py2jemris/rf_20.h5


BIN
libs/lf-scanner/py2jemris/rf_21.h5


BIN
libs/lf-scanner/py2jemris/rf_22.h5


BIN
libs/lf-scanner/py2jemris/rf_23.h5


BIN
libs/lf-scanner/py2jemris/rf_24.h5


BIN
libs/lf-scanner/py2jemris/rf_25.h5


BIN
libs/lf-scanner/py2jemris/rf_3.h5


BIN
libs/lf-scanner/py2jemris/rf_4.h5


BIN
libs/lf-scanner/py2jemris/rf_5.h5


BIN
libs/lf-scanner/py2jemris/rf_6.h5


BIN
libs/lf-scanner/py2jemris/rf_7.h5


BIN
libs/lf-scanner/py2jemris/rf_8.h5


BIN
libs/lf-scanner/py2jemris/rf_9.h5


BIN
libs/lf-scanner/py2jemris/sample.h5


+ 307 - 0
libs/lf-scanner/py2jemris/seq2xml.py

@@ -0,0 +1,307 @@
+# seq2xml.py : converts Pulseq (.seq) files into JEMRIS (.xml) sequences
+# Gehua Tong
+# March 2020
+
+from LF_scanner.pypulseq.Sequence.sequence import Sequence
+from LF_scanner.pypulseq.calc_duration import calc_duration
+import xml.etree.ElementTree as ET
+import h5py
+import numpy as np
+from math import pi
+
+
+# Notes
+# This is for generating an .xml file for input into JEMRIS simulator, from a Pulseq .seq file
+# The opposite philosophies make the .xml encoding suboptimal for storage
+# (because seq files consists of flattened-out Blocks while the JEMRIS format minimizes repetition using loops
+#  and consists of many cross-referencing of parameters)
+
+# Consider: for virtual scanner, have scripts that generate .xml and .seq at the same time (looped vs. flattened)
+# (but isn't JEMRIS already doing that? JEMRIS does have an "output to pulseq" functionality)
+# though then, having a Python interface instead of a MATLAB one is helpful in the open-source aspect
+
+
+# Unit conversion constants (comment with units before & after)
+rf_const = 2 * pi / 1000  # from Pulseq[Hz]=[1/s] to JEMRIS[rad/ms] rf magnitude conversion constant
+g_const = 2 * pi / 1e6  # from Pulseq [Hz/m] to JEMRIS [(rad/ms)/mm] gradient conversion constant
+slew_const = g_const / 1000  # from Pulseq [Hz/(m*s)] to JEMRIS [(rad/ms)/(mm*ms)]
+ga_const = 2 * pi / 1000  # from Pulseq[1/m] to JEMRIS [2*pi/mm] gradient area conversion constant
+sec2ms = 1000  # time conversion constant
+rad2deg = 180/pi
+freq_const = 2 * pi / 1000 # From Hz to rad/ms
+
+def seq2xml(seq, seq_name, out_folder):
+    """
+    # Takes a Pulseq sequence and converts it into .xml format for JEMRIS
+    # All RF and gradient shapes are stored as .h5 files
+
+    Inputs
+    ------
+    seq : pypulseq.Sequence.sequence.Sequence
+    seq_name : name of output .xml file
+    out_folder : str
+        Path to output folder for .xml file
+
+    Returns
+    -------
+    seq_tree : xml.etree.ElementTree
+        Tree object used for generating the sequence .xml file
+    seq_path : str
+        Path to stored .xml sequence
+    """
+
+    # Parameters is the root of the xml
+    root = ET.Element("Parameters")
+    # Add gradient limits (seem to be the only parameters shared by both formats)
+    # TODO check units
+    root.set("GradMaxAmpl", str(seq.system.max_grad*g_const))
+    root.set("GradSlewRate", str(seq.system.max_slew*slew_const))
+
+    # ConcatSequence is the element for the sequence itself;
+    # Allows addition of multiple AtomicSequence
+    C0 = ET.SubElement(root, "ConcatSequence")
+
+    # Use helper functions to save all RF and only arbitrary gradient info
+    rf_shapes_path_dict = save_rf_library_info(seq, out_folder)
+    grad_shapes_path_dict = save_grad_library_info(seq, out_folder)
+    #print(grad_shapes_path_dict)
+    #///////////////////////////////////////////////////////////////////////////
+
+
+    rf_name_ind = 1
+    grad_name_ind = 1
+    delay_name_ind = 1
+    adc_name_ind = 1
+
+
+    ##### Main loop! #####
+    # Go through all blocks and add in events; each block is one AtomicSequence
+    for block_ind in range(1,len(seq.block_events)+1):
+        blk = seq.get_block(block_ind).__dict__
+        exists_adc = 'adc' in blk.keys()
+        adc_already_added = False
+        # Note: "EmptyPulse" class seems to allow variably spaced ADC sampling
+        # Distinguish between delay and others
+        # Question: in pulseq, does delay happen together with other events?
+        #           (for now, we assume delay always happens by itself)
+        # About name of atomic sequences: not adding names for now;
+        # (Likely it will cause no problems because there is no cross-referencing)
+        C_block = ET.SubElement(C0, "ATOMICSEQUENCE")
+        C_block.set("Name", f'C{block_ind}')
+        for key in blk.keys():
+            # Case of RF pulse
+            if key == 'rf':
+                rf = blk['rf']
+                if not (rf is None):
+                    rf_atom = ET.SubElement(C_block, "EXTERNALRFPULSE")
+
+                    rf_atom.set("Name", f'R{rf_name_ind}')
+                    rf_name_ind += 1
+
+                    rf_atom.set("InitialDelay", str(rf.delay*sec2ms))
+                    rf_atom.set("InitialPhase", str(rf.phase_offset*rad2deg))
+                    rf_atom.set("Frequency", str(rf.freq_offset*freq_const))
+                    # Find ID of this rf event
+                    rf_id = seq.block_events[block_ind][1]
+                    rf_atom.set("Filename", rf_shapes_path_dict[rf_id])
+                    rf_atom.set("Scale","1")
+                    rf_atom.set("Interpolate", "0") # Do interpolate
+
+            gnames_map = {'gx':2, 'gy':3, 'gz':4}
+            if key in ['gx', 'gy', 'gz']:
+                g = blk[key]
+                if not (g is None):
+                    if g.type == "trap":
+                        if g.amplitude != 0:
+                            g_atom = ET.SubElement(C_block, "TRAPGRADPULSE")
+                            g_atom.set("Name", f'G{grad_name_ind}')
+                            grad_name_ind += 1
+                            if g.flat_time > 0:
+                                # 1. Case where flat_time is nonzero
+                                # Second, fix FlatTopArea and FlatTopTime
+                                g_atom.set("FlatTopArea", str(g.flat_area*ga_const))
+                                g_atom.set("FlatTopTime", str(g.flat_time*sec2ms))
+
+                                # Last, set axis and delay
+                            else:
+                                # 2. Case of triangular pulse (i.e. no flat part)
+                                g_atom.set("MaxAmpl", str(np.absolute(g.amplitude*g_const))) # limit amplitude
+                                g_atom.set("Area", str(0.5*(g.rise_time + g.fall_time)*g.amplitude*ga_const))
+
+                            # Third, limit duration by limiting slew rate
+                            g_atom.set("SlewRate", str((g.amplitude * g_const) / (g.fall_time * sec2ms)))
+                            g_atom.set("Asymmetric", str(g.fall_time / g.rise_time))
+                            g_atom.set("Axis", key.upper())
+                            g_atom.set("InitialDelay", str(g.delay*sec2ms))
+
+
+                        # Add ADC if it exists and then "mark as complete"
+                    elif g.type == "grad":
+                        # Set arbitrary grad parameters
+                        # Need to load h5 file again, just like in RF
+                        g_id = seq.block_events[block_ind][gnames_map[key]]
+                        g_atom = ET.SubElement(C_block, "EXTERNALGRADPULSE")
+                        g_atom.set("Name", f'G{grad_name_ind}')
+                        grad_name_ind += 1
+
+                        g_atom.set("Axis", key.upper())
+                        g_atom.set("Filename", grad_shapes_path_dict[g_id])
+                        g_atom.set("Scale","1")
+                        g_atom.set("Interpolate","0")
+                        g_atom.set("InitialDelay",str(g.delay*sec2ms))
+
+                    else:
+                        print(f'Gradient type "{g.type}" indicated')
+                        raise ValueError("Gradient's type should be either trap or grad")
+
+                    if exists_adc and not adc_already_added:
+                        adc = blk['adc']
+                        if not (adc is None):
+                            dwell = adc.dwell*sec2ms
+                            adc_delay = adc.delay*sec2ms
+                            Nro = adc.num_samples
+
+                            gzero_adc = ET.SubElement(C_block, "TRAPGRADPULSE")
+                            gzero_adc.set("Name", f'S{adc_name_ind}')
+                            adc_name_ind += 1
+
+                            gzero_adc.set("ADCs", str(Nro))
+                            gzero_adc.set("FlatTopTime", str(dwell*Nro))
+                            gzero_adc.set("FlatTopArea","0")
+                            gzero_adc.set("InitialDelay", str(adc_delay))
+
+                            adc_already_added = True
+                            # Now, it always attach ADC to the first gradient found among keys()
+                            # This might be tricky
+                            # suggestion 1: check the duration of gradient?
+                            # suggestion 2: just do any gradient and hope it works
+                            # suggestion 3: read the JEMRIS documentation/try on GUI
+
+            if key == 'delay':
+                delay_dur = blk['delay'].delay
+                delay_atom = ET.SubElement(C0, "DELAYATOMICSEQUENCE")
+
+                delay_atom.set("Name",f'D{delay_name_ind}')
+                delay_name_ind += 1
+
+                delay_atom.set("Delay",str(delay_dur*sec2ms))
+                delay_atom.set("DelayType","B2E")
+
+    # Output it!
+    seq_tree = ET.ElementTree(root)
+
+    seq_path = out_folder + '/' + seq_name + '.xml'
+    seq_tree.write(seq_path)
+
+    return seq_tree, seq_path
+
+def save_rf_library_info(seq, out_folder):
+    """
+    Helper function that stores distinct RF waveforms for seq2xml
+    """
+    # RF library
+    rf_shapes_path_dict = {}
+    for rf_id in list(seq.rf_library.data.keys()): # for each RF ID
+        # JEMRIS wants:
+        # "Filename":  A HDF5-file with a single dataset "extpulse"
+        # of size N x 3 where the 1st column holds the time points,
+        # and 2nd and 3rd column hold amplitudes and phases, respectively.
+        # Phase units should be radians.
+        # Time is assumed to increase and start at zero.
+        # The last time point defines the length of the pulse.
+        file_path_partial = f'rf_{int(rf_id)}.h5'
+        file_path_full = out_folder + '/' + file_path_partial
+        # De-compress using inbuilt PyPulseq method
+        rf = seq.rf_from_lib_data(seq.rf_library.data[rf_id])
+        # Only extract time, magnitude, and phase
+        # We leave initial phase and freq offset to the main conversion loop)
+        times = rf.t
+        magnitude = np.absolute(rf.signal)
+        phase = np.angle(rf.signal)
+
+        N = len(magnitude)
+        # Create file
+        f = h5py.File(file_path_full, 'a')
+        if "extpulse" in f.keys():
+            del f["extpulse"]
+
+        #f.create_dataset("extpulse", (N,3), dtype='f')
+        f.create_dataset("extpulse",(3,N),dtype='f')
+
+
+        times = times - times[0]
+        f["extpulse"][0,:] = times*sec2ms#*sec2ms
+        f["extpulse"][1,:] = magnitude*rf_const
+        f["extpulse"][2,:] = phase#"Phase should be radians"
+        f.close()
+        rf_shapes_path_dict[rf_id] = file_path_partial
+
+    return rf_shapes_path_dict
+
+
+# Helper function
+def save_grad_library_info(seq, out_folder):
+    """
+    Helper function that stores distinct gradients for seq2xml
+    """
+
+    #file_paths = [out_folder + f'grad_{int(grad_id)}.h5' for grad_id in range(1,N_grad_id+1)]
+    grad_shapes_path_dict = {}
+    processed_g_inds = []
+
+    for nb in range(1,len(seq.block_events)+1):
+        gx_ind, gy_ind, gz_ind = seq.block_events[nb][2:5]
+        for axis_ind, g_ind in enumerate([gx_ind, gy_ind, gz_ind]):
+            # Only save a gradient file if ...(a) it has non-zero index
+            #                                 (b) it is type 'grad', not 'trap'
+            #      s                       and (c) its index has not been processed
+            if g_ind != 0 and len(seq.grad_library.data[g_ind]) == 3 \
+                and g_ind not in processed_g_inds:
+                print(f'Adding Gradient Number {g_ind}')
+                this_block = seq.get_block(nb)
+                file_path_partial = f'grad_{int(g_ind)}.h5'
+                file_path_full = out_folder + '/' + file_path_partial
+
+                #TODO make it work for x/y/z
+                if axis_ind == 0:
+                    t_points = this_block.gx.t
+                    g_shape = this_block.gx.waveform
+                elif axis_ind == 1:
+                    t_points = this_block.gy.t
+                    g_shape = this_block.gy.waveform
+                elif axis_ind == 2:
+                    t_points = this_block.gz.t
+                    g_shape = this_block.gz.waveform
+
+                N = len(t_points)
+                # Create file
+                f = h5py.File(file_path_full, 'a')
+                if "extpulse" in f.keys():
+                    del f["extpulse"]
+                f.create_dataset("extpulse", (2,N), dtype='f')
+                f["extpulse"][0,:] = t_points * sec2ms
+                f["extpulse"][1,:] = g_shape * g_const
+                f.close()
+                grad_shapes_path_dict[g_ind] = file_path_partial
+                processed_g_inds.append(g_ind)
+
+    return grad_shapes_path_dict
+
+
+
+
+
+
+
+if __name__ == '__main__':
+    print('')
+    seq = Sequence()
+    seq.read('sim/test0504/gre32.seq')
+    seq2xml(seq, seq_name='gre32_twice', out_folder='sim/test0504')
+#    seq.read('seq_files/spgr_gspoil_N16_Ns1_TE5ms_TR10ms_FA30deg.seq')
+    #seq.read('benchmark_seq2xml/gre_jemris.seq')
+#    seq.read('try_seq2xml/spgr_gspoil_N15_Ns1_TE5ms_TR10ms_FA30deg.seq')
+    #seq.read('orc_test/seq_2020-02-26_ORC_54_9_384_1.seq')
+    #stree = seq2xml(seq, seq_name="ORC-Marina", out_folder='orc_test')
+
+

+ 318 - 0
libs/lf-scanner/py2jemris/seq2xml_fixed_delay.py

@@ -0,0 +1,318 @@
+# seq2xml.py : converts Pulseq (.seq) files into JEMRIS (.xml) sequences
+# Gehua Tong
+# March 2020
+
+# from pypulseq.Sequence.sequence import Sequence
+# from pypulseq.calc_duration import calc_duration
+import pypulseq as pp
+import xml.etree.ElementTree as ET
+import h5py
+import numpy as np
+from math import pi
+
+def save_rf_library_info(seq, out_folder):
+    """
+    Helper function that stores distinct RF waveforms for seq2xml
+    """
+    # RF library
+    rf_shapes_path_dict = {}
+    for rf_id in list(seq.rf_library.data.keys()): # for each RF ID
+        # JEMRIS wants:
+        # "Filename":  A HDF5-file with a single dataset "extpulse"
+        # of size N x 3 where the 1st column holds the time points,
+        # and 2nd and 3rd column hold amplitudes and phases, respectively.
+        # Phase units should be radians.
+        # Time is assumed to increase and start at zero.
+        # The last time point defines the length of the pulse.
+        file_path_partial = f'rf_{int(rf_id)}.h5'
+        file_path_full = out_folder + '/' + file_path_partial
+        # De-compress using inbuilt PyPulseq method
+        rf = seq.rf_from_lib_data(seq.rf_library.data[rf_id])
+        # Only extract time, magnitude, and phase
+        # We leave initial phase and freq offset to the main conversion loop)
+        times = rf.t
+        magnitude = np.absolute(rf.signal)
+        phase = np.angle(rf.signal)
+
+        N = len(magnitude)
+        # Create file
+        f = h5py.File(file_path_full, 'a')
+        if "extpulse" in f.keys():
+            del f["extpulse"]
+
+        #f.create_dataset("extpulse", (N,3), dtype='f')
+        f.create_dataset("extpulse",(3,N),dtype='f')
+
+
+        times = times - times[0]
+        f["extpulse"][0,:] = times*sec2ms#*sec2ms
+        f["extpulse"][1,:] = magnitude*rf_const
+        f["extpulse"][2,:] = phase#"Phase should be radians"
+        f.close()
+        rf_shapes_path_dict[rf_id] = file_path_partial
+
+    return rf_shapes_path_dict
+
+
+# Helper function
+def save_grad_library_info(seq, out_folder):
+    """
+    Helper function that stores distinct gradients for seq2xml
+    """
+
+    #file_paths = [out_folder + f'grad_{int(grad_id)}.h5' for grad_id in range(1,N_grad_id+1)]
+    grad_shapes_path_dict = {}
+    processed_g_inds = []
+
+    for nb in range(1,len(seq.block_events)+1):
+        gx_ind, gy_ind, gz_ind = seq.block_events[nb][2:5]
+        for axis_ind, g_ind in enumerate([gx_ind, gy_ind, gz_ind]):
+            # Only save a gradient file if ...(a) it has non-zero index
+            #                                 (b) it is type 'grad', not 'trap'
+            #      s                       and (c) its index has not been processed
+            if g_ind != 0 and len(seq.grad_library.data[g_ind]) == 3 \
+                and g_ind not in processed_g_inds:
+                print(f'Adding Gradient Number {g_ind}')
+                this_block = seq.get_block(nb)
+                file_path_partial = f'grad_{int(g_ind)}.h5'
+                file_path_full = out_folder + '/' + file_path_partial
+
+                #TODO make it work for x/y/z
+                if axis_ind == 0:
+                    t_points = this_block.gx.t
+                    g_shape = this_block.gx.waveform
+                elif axis_ind == 1:
+                    t_points = this_block.gy.t
+                    g_shape = this_block.gy.waveform
+                elif axis_ind == 2:
+                    t_points = this_block.gz.t
+                    g_shape = this_block.gz.waveform
+
+                N = len(t_points)
+                # Create file
+                f = h5py.File(file_path_full, 'a')
+                if "extpulse" in f.keys():
+                    del f["extpulse"]
+                f.create_dataset("extpulse", (2,N), dtype='f')
+                f["extpulse"][0,:] = t_points * sec2ms
+                f["extpulse"][1,:] = g_shape * g_const
+                f.close()
+                grad_shapes_path_dict[g_ind] = file_path_partial
+                processed_g_inds.append(g_ind)
+
+    return grad_shapes_path_dict
+
+# Notes
+# This is for generating an .xml file for input into JEMRIS simulator, from a Pulseq .seq file
+# The opposite philosophies make the .xml encoding suboptimal for storage
+# (because seq files consists of flattened-out Blocks while the JEMRIS format minimizes repetition using loops
+#  and consists of many cross-referencing of parameters)
+
+# Consider: for virtual scanner, have scripts that generate .xml and .seq at the same time (looped vs. flattened)
+# (but isn't JEMRIS already doing that? JEMRIS does have an "output to pulseq" functionality)
+# though then, having a Python interface instead of a MATLAB one is helpful in the open-source aspect
+
+
+# Unit conversion constants (comment with units before & after)
+rf_const = 2 * pi / 1000  # from Pulseq[Hz]=[1/s] to JEMRIS[rad/ms] rf magnitude conversion constant
+g_const = 2 * pi / 1e6  # from Pulseq [Hz/m] to JEMRIS [(rad/ms)/mm] gradient conversion constant
+slew_const = g_const / 1000  # from Pulseq [Hz/(m*s)] to JEMRIS [(rad/ms)/(mm*ms)]
+ga_const = 2 * pi / 1000  # from Pulseq[1/m] to JEMRIS [2*pi/mm] gradient area conversion constant
+sec2ms = 1000  # time conversion constant
+rad2deg = 180/pi
+freq_const = 2 * pi / 1000 # From Hz to rad/ms
+
+#def seq2xml(seq, seq_name, out_folder):
+"""
+# Takes a Pulseq sequence and converts it into .xml format for JEMRIS
+# All RF and gradient shapes are stored as .h5 files
+
+Inputs
+------
+seq : pypulseq.Sequence.sequence.Sequence
+seq_name : name of output .xml file
+out_folder : str
+    Path to output folder for .xml file
+
+Returns
+-------
+seq_tree : xml.etree.ElementTree
+    Tree object used for generating the sequence .xml file
+seq_path : str
+    Path to stored .xml sequence
+"""
+
+seq = pp.Sequence()
+seq.read('C:\\MRI_seq\\pypulseq\\seq_examples\\new_scripts\\epi_se_pypulseq.seq')
+seq_name='epi_se_pypulseq.seq_fixed_delay'
+out_folder='C:\\MRI_seq\\pypulseq\\seq_examples\\new_scripts'
+
+#seq.read('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_SE\\t1_SE_matrx32x32.seq')
+#seq_name='t1_SE_matrx32x32_fixed_delay'
+#out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_SE'
+
+# Parameters is the root of the xml
+root = ET.Element("Parameters")
+# Add gradient limits (seem to be the only parameters shared by both formats)
+# TODO check units
+root.set("GradMaxAmpl", str(seq.system.max_grad*g_const))
+root.set("GradSlewRate", str(seq.system.max_slew*slew_const))
+
+# ConcatSequence is the element for the sequence itself;
+# Allows addition of multiple AtomicSequence
+C0 = ET.SubElement(root, "ConcatSequence")
+
+# Use helper functions to save all RF and only arbitrary gradient info
+rf_shapes_path_dict = save_rf_library_info(seq, out_folder)
+grad_shapes_path_dict = save_grad_library_info(seq, out_folder)
+#print(grad_shapes_path_dict)
+#///////////////////////////////////////////////////////////////////////////
+
+
+rf_name_ind = 1
+grad_name_ind = 1
+delay_name_ind = 1
+adc_name_ind = 1
+
+
+##### Main loop! #####
+# Go through all blocks and add in events; each block is one AtomicSequence
+for block_ind in range(1,len(seq.block_events)+1):
+#for block_ind in range(3,4):
+    blk = seq.get_block(block_ind).__dict__
+    exists_adc = 'adc' in blk.keys()
+    adc_already_added = False
+    # Note: "EmptyPulse" class seems to allow variably spaced ADC sampling
+    # Distinguish between delay and others
+    # Question: in pulseq, does delay happen together with other events?
+    #           (for now, we assume delay always happens by itself)
+    # About name of atomic sequences: not adding names for now;
+    # (Likely it will cause no problems because there is no cross-referencing)
+    C_block = ET.SubElement(C0, "ATOMICSEQUENCE")
+    C_block.set("Name", f'C{block_ind}')
+    for key in blk.keys():
+        # Case of RF pulse
+        if key == 'rf':
+            rf = blk['rf']
+            if type(rf) != type(None):
+                rf_atom = ET.SubElement(C_block, "EXTERNALRFPULSE")
+    
+                rf_atom.set("Name", f'R{rf_name_ind}')
+                rf_name_ind += 1
+
+                rf_atom.set("InitialDelay", str(rf.delay*sec2ms))
+                rf_atom.set("InitialPhase", str(rf.phase_offset*rad2deg))
+                rf_atom.set("Frequency", str(rf.freq_offset*freq_const))
+                # Find ID of this rf event
+                rf_id = seq.block_events[block_ind][1]
+                rf_atom.set("Filename", rf_shapes_path_dict[rf_id])
+                rf_atom.set("Scale","1")
+                rf_atom.set("Interpolate", "0") # Do interpolate
+
+        gnames_map = {'gx':2, 'gy':3, 'gz':4}
+        if key in ['gx', 'gy', 'gz']:
+            g = blk[key]
+            if type(g) != type(None): 
+                if g.type == "trap":
+                    if g.amplitude != 0:
+                        g_atom = ET.SubElement(C_block, "TRAPGRADPULSE")
+                        g_atom.set("Name", f'G{grad_name_ind}')
+                        grad_name_ind += 1
+                        if g.flat_time > 0:
+                            # 1. Case where flat_time is nonzero
+                            # Second, fix FlatTopArea and FlatTopTime
+                            g_atom.set("FlatTopArea", str(g.flat_area*ga_const))
+                            g_atom.set("FlatTopTime", str(g.flat_time*sec2ms))
+    
+                            # Last, set axis and delay
+                        else:
+                            # 2. Case of triangular pulse (i.e. no flat part)
+                            g_atom.set("MaxAmpl", str(np.absolute(g.amplitude*g_const))) # limit amplitude
+                            g_atom.set("Area", str(0.5*(g.rise_time + g.fall_time)*g.amplitude*ga_const))
+    
+                        # Third, limit duration by limiting slew rate
+                        g_atom.set("SlewRate", str((g.amplitude * g_const) / (g.fall_time * sec2ms)))
+                        g_atom.set("Asymmetric", str(g.fall_time / g.rise_time))
+                        g_atom.set("Axis", key.upper())
+                        g_atom.set("InitialDelay", str(g.delay*sec2ms))
+    
+    
+                    # Add ADC if it exists and then "mark as complete"
+                elif g.type == "grad":
+                    # Set arbitrary grad parameters
+                    # Need to load h5 file again, just like in RF
+                    g_id = seq.block_events[block_ind][gnames_map[key]]
+                    g_atom = ET.SubElement(C_block, "EXTERNALGRADPULSE")
+                    g_atom.set("Name", f'G{grad_name_ind}')
+                    grad_name_ind += 1
+    
+                    g_atom.set("Axis", key.upper())
+                    g_atom.set("Filename", grad_shapes_path_dict[g_id])
+                    g_atom.set("Scale","1")
+                    g_atom.set("Interpolate","0")
+                    g_atom.set("InitialDelay",str(g.delay*sec2ms))
+    
+                else:
+                    print(f'Gradient type "{g.type}" indicated')
+                    raise ValueError("Gradient's type should be either trap or grad")
+    
+                if exists_adc and not adc_already_added:
+                    adc = blk['adc']
+                    if type(adc) != type(None): 
+                        dwell = adc.dwell*sec2ms
+                        adc_delay = adc.delay*sec2ms
+                        Nro = adc.num_samples
+        
+                        gzero_adc = ET.SubElement(C_block, "TRAPGRADPULSE")
+                        gzero_adc.set("Name", f'S{adc_name_ind}')
+                        adc_name_ind += 1
+        
+                        gzero_adc.set("ADCs", str(Nro))
+                        gzero_adc.set("FlatTopTime", str(dwell*Nro))
+                        gzero_adc.set("FlatTopArea","0")
+                        gzero_adc.set("InitialDelay", str(adc_delay))
+        
+                        adc_already_added = True
+                    # Now, it always attach ADC to the first gradient found among keys()
+                    # This might be tricky
+                    # suggestion 1: check the duration of gradient?
+                    # suggestion 2: just do any gradient and hope it works
+                    # suggestion 3: read the JEMRIS documentation/try on GUI
+
+    if type(blk['gx']) == type(None) and type(blk['gy']) == type(None) and type(blk['gz']) == type(None) and type(blk['rf']) == type(None) and type(blk['adc']) == type(None) :
+        delay_dur = blk['block_duration']
+        delay_atom = ET.SubElement(C0, "DELAYATOMICSEQUENCE")
+
+        delay_atom.set("Name",f'D{delay_name_ind}')
+        delay_name_ind += 1
+
+        delay_atom.set("Delay",str(delay_dur*sec2ms))
+        delay_atom.set("DelayType","B2E")
+
+# Output it!
+seq_tree = ET.ElementTree(root)
+
+seq_path = out_folder + '/' + seq_name + '.xml'
+seq_tree.write(seq_path)
+
+#return seq_tree, seq_path
+
+
+
+
+
+
+
+
+# if __name__ == '__main__':
+#     print('')
+#     seq = pp.Sequence()
+#     seq.read('gre_pypulseq_1slice_for_sim.seq')
+#     seq2xml(seq, seq_name='gre_test1', out_folder='test')
+#    seq.read('seq_files/spgr_gspoil_N16_Ns1_TE5ms_TR10ms_FA30deg.seq')
+    #seq.read('benchmark_seq2xml/gre_jemris.seq')
+#    seq.read('try_seq2xml/spgr_gspoil_N15_Ns1_TE5ms_TR10ms_FA30deg.seq')
+    #seq.read('orc_test/seq_2020-02-26_ORC_54_9_384_1.seq')
+    #stree = seq2xml(seq, seq_name="ORC-Marina", out_folder='orc_test')
+
+

+ 40 - 0
libs/lf-scanner/py2jemris/sim/8chheadcyl.xml

@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<CoilArray>
+   <BIOTSAVARTLOOP Azimuth="0" Dim="2" Extent="256" Name="C1" Points="64" Polar="90" Radius="100"
+                   XPos="256"
+                   YPos="0"
+                   ZPos="0"/>
+   <BIOTSAVARTLOOP Azimuth="45" Dim="2" Extent="256" Name="C2" Points="64" Polar="90" Radius="100"
+                   XPos="181.019"
+                   YPos="181.019"
+                   ZPos="0"/>
+   <BIOTSAVARTLOOP Azimuth="90" Dim="2" Extent="256" Name="C3" Points="64" Polar="90" Radius="100"
+                   XPos="0"
+                   YPos="256"
+                   ZPos="0"/>
+   <BIOTSAVARTLOOP Azimuth="135" Dim="2" Extent="256" Name="C4" Points="64" Polar="90"
+                   Radius="100"
+                   XPos="-181.019"
+                   YPos="181.019"
+                   ZPos="0"/>
+   <BIOTSAVARTLOOP Azimuth="180" Dim="2" Extent="256" Name="C5" Points="64" Polar="90"
+                   Radius="100"
+                   XPos="-256"
+                   YPos="0"
+                   ZPos="0"/>
+   <BIOTSAVARTLOOP Azimuth="225" Dim="2" Extent="256" Name="C6" Points="64" Polar="90"
+                   Radius="100"
+                   XPos="-181.019"
+                   YPos="-181.019"
+                   ZPos="0"/>
+   <BIOTSAVARTLOOP Azimuth="270" Dim="2" Extent="256" Name="C7" Points="64" Polar="90"
+                   Radius="100"
+                   XPos="0"
+                   YPos="-256"
+                   ZPos="0"/>
+   <BIOTSAVARTLOOP Azimuth="315" Dim="2" Extent="256" Name="C8" Points="64" Polar="90"
+                   Radius="100"
+                   XPos="181.019"
+                   YPos="-181.019"
+                   ZPos="0"/>
+</CoilArray>

+ 0 - 0
libs/lf-scanner/py2jemris/sim/__init__.py


+ 22 - 0
libs/lf-scanner/py2jemris/sim/epi.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Parameters FOVx="256" FOVy="256" GradMaxAmpl="2" GradSlewRate="10" Name="P" Nx="64" Ny="64" TE="50" TR="100">
+   <ConcatSequence Name="C1">
+      <AtomicSequence Name="A1">
+         <HARDRFPULSE Axis="RF" Duration="0.1" FlipAngle="90" Name="P1"/>
+      </AtomicSequence>
+      <AtomicSequence Name="A2">
+         <TrapGradPulse Area="-0.5*abs(A)" Axis="GX" Name="P2" Observe="A=P4.Area"/>
+         <TrapGradPulse Area="KMY" Axis="GY" Name="P3" Observe="KMY=P.KMAXy"/>
+      </AtomicSequence>
+      <DelayAtomicSequence Delay="TE" DelayType="C2C" Name="D1" Observe="TE=P.TE" StartSeq="A1" StopSeq="C2"/>
+      <ConcatSequence Name="C2" Observe="NY=P.Ny" Repetitions="NY">
+         <AtomicSequence Name="A3">
+            <TrapGradPulse ADCs="NX" Axis="GX" FlatTopArea="2*KMX*(-1)^C" FlatTopTime="1" Name="P4" Observe="KMX=P.KMAXx, C=C2.Counter, NX=P.Nx"/>
+         </AtomicSequence>
+         <AtomicSequence Name="A4">
+            <TrapGradPulse Area="ite(1+C,R,0,-DKY)" Axis="GY" Name="P5" Observe="DKY=P.DKy, C=C2.Counter, R=C2.Repetitions"/>
+         </AtomicSequence>
+      </ConcatSequence>
+      <DelayAtomicSequence Delay="TR" DelayType="B2E" Name="D2" Observe="TR=P.TR" StartSeq="A1"/>
+   </ConcatSequence>
+</Parameters>

+ 23 - 0
libs/lf-scanner/py2jemris/sim/gre.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Parameters FOVx="128" FOVy="128" FOVz="1" Name="P" Nx="32" Ny="32" Nz="1" TE="8" TR="50">
+   <ConcatSequence Name="R">
+      <ConcatSequence Name="C" Observe="NY=P.Ny" Repetitions="NY">
+         <ATOMICSEQUENCE Name="A1">
+            <HARDRFPULSE Axis="RF" Duration="0.1" FlipAngle="20" InitialPhase="C*(C+1)*50" Name="P1" Observe="C=C.Counter"/>
+         </ATOMICSEQUENCE>
+         <DELAYATOMICSEQUENCE Delay="TE" DelayType="C2C" Name="D1" Observe="TE=P.TE" StartSeq="A1" StopSeq="A3"/>
+         <ATOMICSEQUENCE Name="A2">
+            <TRAPGRADPULSE Area="-A/2" Axis="GX" Name="P2" Observe="A=P4.Area"/>
+            <TRAPGRADPULSE Area="-KMY+C*DKY" Axis="GY" Name="P3" Observe="KMY=P.KMAXy, C=C.Counter, DKY=P.DKy"/>
+         </ATOMICSEQUENCE>
+         <ATOMICSEQUENCE Name="A3">
+            <TRAPGRADPULSE ADCs="NX" Axis="GX" FlatTopArea="2*KMX" FlatTopTime="4" Name="P4" Observe="KMX=P.KMAXx, NX=P.Nx" PhaseLock="1"/>
+         </ATOMICSEQUENCE>
+         <ATOMICSEQUENCE Name="A4">
+            <TRAPGRADPULSE Area="1.5*A" Axis="GX" Name="P6" Observe="A=P4.Area"/>
+            <TRAPGRADPULSE Area="-A" Axis="GY" Name="P7" Observe="A=P3.Area"/>
+         </ATOMICSEQUENCE>
+         <DELAYATOMICSEQUENCE Delay="TR" DelayType="B2E" Name="D2" Observe="TR=P.TR" StartSeq="A1"/>
+      </ConcatSequence>
+   </ConcatSequence>
+</Parameters>

BIN
libs/lf-scanner/py2jemris/sim/ismrm_abstract/spgr_64_v2/phantom_bottles.mat


BIN
libs/lf-scanner/py2jemris/sim/sample.h5


+ 31 - 0
libs/lf-scanner/py2jemris/sim/tse.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Parameters FOVx="64" FOVy="64" FOVz="1" Name="P" Nx="32" Ny="32" Nz="1" TE="15" TR="150">
+   <ConcatSequence Name="TSE">
+      <ConcatSequence Name="O" Observe="NY=P.Ny, R=I.Repetitions" Repetitions="NY/R">
+         <ATOMICSEQUENCE Name="A1">
+            <HARDRFPULSE Axis="RF" Duration="0.1" FlipAngle="90" Name="P1"/>
+         </ATOMICSEQUENCE>
+         <ATOMICSEQUENCE Name="A2">
+            <TRAPGRADPULSE Area="0.5*A" Axis="GX" Name="P2" Observe="A=P5.Area"/>
+         </ATOMICSEQUENCE>
+         <DELAYATOMICSEQUENCE Delay="TE/2" DelayType="B2E" Name="D1" Observe="TE=P.TE" StartSeq="A1"/>
+         <CONCATSEQUENCE Name="I" Repetitions="4">
+            <ATOMICSEQUENCE Name="A3">
+               <HARDRFPULSE Axis="RF" Duration="0.1" FlipAngle="180" InitialPhase="90" Name="P3" Refocusing="1"/>
+            </ATOMICSEQUENCE>
+            <ATOMICSEQUENCE Name="A4">
+               <TRAPGRADPULSE Area="-KMY+DKY*(CI+RI*CO)" Axis="GY" Name="P4" Observe="KMY=P.KMAXy, DKY=P.DKy, CI=I.Counter, RI=I.Repetitions, CO=O.Counter"/>
+            </ATOMICSEQUENCE>
+            <DELAYATOMICSEQUENCE Delay="TE/2" DelayType="C2C" Name="D2" Observe="TE=P.TE" StartSeq="A3" StopSeq="A5"/>
+            <ATOMICSEQUENCE Name="A5">
+               <TRAPGRADPULSE ADCs="NX" Axis="GX" FlatTopArea="2*KMX" FlatTopTime="4" Name="P5" Observe="KMX=P.KMAXx, NX=P.Nx"/>
+            </ATOMICSEQUENCE>
+            <ATOMICSEQUENCE Name="A6">
+               <TRAPGRADPULSE Area="-A" Axis="GY" Name="P6" Observe="A=P4.Area"/>
+            </ATOMICSEQUENCE>
+            <DELAYATOMICSEQUENCE Delay="TE" DelayType="B2E" Name="D3" Observe="TE=P.TE" StartSeq="A3"/>
+         </CONCATSEQUENCE>
+         <DELAYATOMICSEQUENCE Delay="TR" DelayType="B2E" Name="D4" Observe="TR=P.TR" StartSeq="A1"/>
+      </ConcatSequence>
+   </ConcatSequence>
+</Parameters>

+ 4 - 0
libs/lf-scanner/py2jemris/sim/uniform.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<CoilArray>
+  <IdealCoil/>
+</CoilArray>

BIN
libs/lf-scanner/py2jemris/sim/utest_outputs/cylindrical.h5


BIN
libs/lf-scanner/py2jemris/sim/utest_outputs/data32_orig.mat


+ 28 - 0
libs/lf-scanner/py2jemris/sim/utest_outputs/gre32.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Parameters FOVx="256" FOVy="256" FOVz="1" Name="P" Nx="32" Ny="32" Nz="1" TE="10" TR="100">
+   <ConcatSequence Name="R">
+      <ConcatSequence Name="C" Observe="NY=P.Ny" Repetitions="NY">
+         <ATOMICSEQUENCE Name="A1">
+            <HARDRFPULSE Axis="RF" Duration="0.1" FlipAngle="20" InitialPhase="0" Name="P1"
+                         Observe="C=C.Counter"/>
+         </ATOMICSEQUENCE>
+         <DELAYATOMICSEQUENCE Delay="TE" DelayType="C2C" Name="D1" Observe="TE=P.TE" StartSeq="A1"
+                              StopSeq="A3"/>
+         <ATOMICSEQUENCE Name="A2">
+            <TRAPGRADPULSE Area="-A/2" Axis="GX" Name="P2" Observe="A=P4.Area"/>
+            <TRAPGRADPULSE Area="-KMY+C*DKY" Axis="GY" Name="P3"
+                           Observe="KMY=P.KMAXy, C=C.Counter, DKY=P.DKy"/>
+         </ATOMICSEQUENCE>
+         <ATOMICSEQUENCE Name="A3">
+            <TRAPGRADPULSE ADCs="NX" Axis="GX" FlatTopArea="2*KMX" FlatTopTime="4" Name="P4"
+                           Observe="KMX=P.KMAXx, NX=P.Nx"
+                           PhaseLock="0"/>
+         </ATOMICSEQUENCE>
+         <ATOMICSEQUENCE Name="A4">
+            <TRAPGRADPULSE Area="1.5*A" Axis="GX" Name="P6" Observe="A=P4.Area"/>
+            <TRAPGRADPULSE Area="-A" Axis="GY" Name="P7" Observe="A=P3.Area"/>
+         </ATOMICSEQUENCE>
+         <DELAYATOMICSEQUENCE Delay="TR" DelayType="B2E" Name="D2" Observe="TR=P.TR" StartSeq="A1"/>
+      </ConcatSequence>
+   </ConcatSequence>
+</Parameters>

BIN
libs/lf-scanner/py2jemris/sim/utest_outputs/signals.h5


+ 1 - 0
libs/lf-scanner/py2jemris/sim/utest_outputs/simu.xml

@@ -0,0 +1 @@
+<simulate name="JEMRIS"><sample name="cylindrical" uri="cylindrical.h5" /><TXcoilarray uri="uniform.xml" /><RXcoilarray uri="uniform.xml" /><parameter ConcomitantFields="0" EvolutionPrefix="evol" EvolutionSteps="0" RandomNoise="0" /><sequence name="gre32" uri="gre32.xml" /><model name="Bloch" type="CVODE" /></simulate>

+ 4 - 0
libs/lf-scanner/py2jemris/sim/utest_outputs/uniform.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<CoilArray>
+  <IdealCoil/>
+</CoilArray>

+ 53 - 0
libs/lf-scanner/py2jemris/sim2xml.py

@@ -0,0 +1,53 @@
+import xml.etree.ElementTree as ET
+from io import BytesIO
+
+def sim2xml(sim_name="simu", seq="example.xml", phantom="sample.h5", Tx="uniform.xml", Rx="uniform.xml",
+            seq_name="Sequence", sample_name="Sample", out_folder_name = None):
+
+    root = ET.Element("simulate")
+    root.set("name", "JEMRIS")
+
+    sample = ET.SubElement(root, "sample")
+    sample.set("name", sample_name)
+    sample.set("uri", phantom)
+
+    TXcoilarray = ET.SubElement(root, "TXcoilarray")
+    TXcoilarray.set("uri", Tx)
+
+    RXcoilarray = ET.SubElement(root, "RXcoilarray")
+    RXcoilarray.set("uri", Rx)
+
+    parameter = ET.SubElement(root, "parameter")
+    parameter.set("RandomNoise", "0")
+    parameter.set("EvolutionSteps", "0")
+    parameter.set("EvolutionPrefix", "evol")
+    parameter.set("ConcomitantFields", "0")
+
+    sequence = ET.SubElement(root, "sequence")
+    sequence.set("name", seq_name)
+    sequence.set("uri", seq)
+
+    model = ET.SubElement(root, "model")
+    model.set("name", "Bloch")
+    model.set("type", "CVODE")
+
+    sim_tree = ET.ElementTree(root)
+    sim_out_path = out_folder_name + '/' + sim_name + '.xml'
+    sim_tree.write(sim_out_path)
+
+    return sim_out_path
+
+
+
+
+# Fig 1. Draw diagram of what py2jemris consists of
+# Fig 2. SDC Debug progress
+
+
+
+
+
+
+if __name__ == '__main__':
+    sim2xml(seq="gre_jemris_seq2xml.xml", phantom="sample.h5", Tx="uniform.xml", Rx="uniform.xml",
+            seq_name="Sequence", sample_name="Sample", out_folder_name="try_seq2xml")

+ 181 - 0
libs/lf-scanner/py2jemris/sim_jemris.py

@@ -0,0 +1,181 @@
+# Caller script for executing a simulation with JEMRIS (prior installation required)
+# Gehua Tong, April 2020
+
+
+
+
+from seq2xml import seq2xml
+from sim2xml import sim2xml
+from recon_jemris import read_jemris_output
+from coil2xml import coil2xml
+import subprocess
+import tkinter as tk
+from tkinter.filedialog import askopenfilename
+
+#from virtualscanner.core import constants
+import h5py
+import os
+
+# Paths
+#PY2JEMRIS_SIM_PATH = constants.SERVER_SIM_BLOCH_PY2JEMRIS_PATH / 'sim'
+from scipy.io import savemat
+import time
+
+def ask_for_sim_files():
+    """Helper function for sim_jemris;
+       Asks the user for simulation files through file system selection
+
+    Returns
+    -------
+    files_list : list
+        A dictionary indicating paths to the files required to construct simu.xml
+
+    """
+    files_list = {}
+    names = ['seq_xml', 'pht_h5', 'tx_xml', 'rx_xml']
+    prompt_list = ['sequence file (.xml)', 'phantom file (.h5)','Tx file (.xml)', 'Rx file (.xml)']
+
+    for u in range(len(prompt_list)-1):
+        print(f"Pick your {prompt_list[u]}.")
+        tk.Tk().withdraw()
+        filename = askopenfilename()
+        files_list[names[u]] = filename
+
+    return files_list
+
+
+
+def run_jemris(working_folder = None):
+    """Runs JEMRIS simulation on system command line
+       Assumes that the working folder contains all required files and
+               that JEMRIS is installed and added to PATH on the operating system
+       Simply, the command "jemris simu.xml" is run and the path to signals.h5 is returned
+
+    Inputs
+    ------
+    working_folder : str
+        Working folder where the simulation is performed
+
+    Returns
+    -------
+    signal path : str or pathlib Path object
+        Path to JEMRIS simulation output data file (this file is always called signals.h5)
+
+    """
+    print("Simulating using JEMRIS ...")
+    # Always run from the py2jemris/sim directory
+    if working_folder is None:
+        working_folder = 'sim'
+    original_wd = os.getcwd()
+    os.chdir(working_folder)
+    print(os.system('dir'))
+    out = os.system('jemris simu.xml')
+    print(out)
+    os.chdir(original_wd)
+    # Find signal.h5
+    if isinstance(working_folder, str):
+        signal_path = working_folder + '/signals.h5'
+    else:
+        signal_path = working_folder / 'signals.h5' # Return the absolute signal path here
+
+
+    return signal_path
+
+def sim_jemris(list_sim_files=None, working_folder=None):
+    """Runs a JEMRIS MR simulation using given .xml and .h5 files
+              based on custom file inputs. Returns complex signal data.
+    Inputs
+    ------
+    list_sim_files : dict
+        Dictionary of paths to relevant simulation files
+    working_folder : str
+        Working folder where the simulation is performed
+
+
+    Returns
+    -------
+    output : dict
+        Complex signal data with 3 fields
+        'Mxy' : Complex representation of transverse magnetization
+        'M_vec' : 3D representation of magnetization (Mx, My, Mz)
+        'T' : Timing of readout points
+
+    """
+
+    # Use interactive option if there is no dictionary input
+    all_files_exist = False
+    while not all_files_exist:
+        try:
+            seq_xml = list_sim_files['seq_xml']
+            pht_h5 = list_sim_files['pht_h5']
+            tx_xml = list_sim_files['tx_xml']
+            rx_xml = list_sim_files['rx_xml']
+
+            all_files_exist = True
+        except:
+            list_sim_files = ask_for_sim_files()
+
+    # Extract sequence and phantom name
+    seq_name = seq_xml[seq_xml.rfind('/')+1:seq_xml.rfind('.xml')]
+    pht_name = pht_h5[pht_h5.rfind('/')+1:pht_h5.rfind('.h5')]
+
+    # Make simu.xml
+    sim2xml(sim_name='simu', seq=seq_xml, phantom=pht_h5, Tx=tx_xml, Rx=rx_xml,
+                        seq_name=seq_name, sample_name=pht_name, out_folder_name=str(working_folder))
+    # Rum JEMRIS on command line
+    signal_path = run_jemris(working_folder)
+    print(signal_path)
+
+    file_discovered = False
+    print((os.path.abspath(signal_path)))
+    while not file_discovered:
+        file_discovered = os.path.exists(os.path.abspath(signal_path))
+        print(file_discovered)
+        time.sleep(2)
+
+    Mxy_out, M_vec_out, times_out = read_jemris_output(signal_path)
+    output = {'Mxy': Mxy_out, "M_vec": M_vec_out, 'T': times_out}
+
+    return output
+
+
+
+from recon_jemris import *
+
+
+
+if __name__ == '__main__':
+
+    # JEMRIS seq.h5
+    #T   = h5read('seq.h5','/seqdiag/T');           % temporal sampling points
+    #RXP = h5read('seq.h5','/seqdiag/RXP');         % RF Receiver phase; unit: radiants; if negative, the TPOI was not an ADC
+    #TXM = h5read('seq.h5','/seqdiag/TXM');         % RF Transmitter magnitude
+    #TXP = h5read('seq.h5','/seqdiag/TXP');         % RF Transmitter phase; unit: radiants
+    #GX  = h5read('seq.h5','/seqdiag/GX');          % physical X-Gradient
+    #GY  = h5read('seq.h5','/seqdiag/GY');          % physical Y-Gradient
+    #GZ  = h5read('seq.h5','/seqdiag/GZ');          % physical Z-Gradient
+
+
+    #['seq_xml', 'pht_h5', 'tx_xml', 'rx_xml', 'working_path'
+    #output = sim_jemris()
+  #  print(output)
+   # sim2xml(seq="gre.xml", phantom="sample.h5", Tx="uniform.xml", Rx="uniform.xml",
+  #        seq_name="Sequence", sample_name="Sample", out_folder_name="sim")
+
+
+    # "Sim test" April 17 for seq2xml
+
+    # First, sim using original gre
+    list_sim_orig = {'seq_xml': 'gre32.xml', 'pht_h5': 'cylindrical.h5', 'tx_xml':'uniform.xml',
+                       'rx_xml': 'uniform.xml'}
+    out = sim_jemris(list_sim_orig, working_folder = 'sim/test0504')
+    savemat('sim/test0504/data32_orig.mat',out)
+
+
+
+    # Second, use twice converted (.xml output of seq2xml)
+    list_sim_twice = {'seq_xml': 'gre32_twice.xml', 'pht_h5': 'cylindrical.h5', 'tx_xml':'uniform.xml',
+                      'rx_xml': 'uniform.xml'}
+   # out = sim_jemris(list_sim_twice, working_folder = 'sim/test0504')
+   # savemat('sim/test0504/data32_twice.mat',out)
+

+ 37 - 0
libs/lf-scanner/py2jemris/sim_py2jemris_ismrm2021.py

@@ -0,0 +1,37 @@
+import os
+from pulseq_jemris_simulator import simulate_pulseq_jemris, recon_jemris
+from scipy.io import savemat, loadmat
+import numpy as np
+from datetime import datetime
+
+
+# SPGR
+# Create phantom
+n = 16
+#phantom_info = {'fov': 0.25, 'N': n, 'type': 'cylindrical', 'dim': 2, 'dir': 'z', 'loc': 0}
+
+
+
+sps = 'sim/ismrm_abstract/spgr_16_pht/spgr_gspoil_N16_Ns1_TE10ms_TR50ms_FA30deg_acq_111920.seq'
+sim_name = 'ismrm_abstract\\spgr_16_pht'
+phtmaps = loadmat('sim/ismrm_abstract/spgr_16_pht/ph2bottles16.mat')
+FOV = 0.25
+N = 16
+dr = FOV/N
+t1map = np.zeros((N,N,1))
+t1map[:,:,0] = 1e-3 * phtmaps['T1map16'] # Original is in ms; convert to seconds
+t2map = np.zeros((N,N,1))
+t2map[:,:,0] = 1e-3 * phtmaps['T2map16'] # Original is in ms; convert to seconds
+pdmap = np.zeros((N,N,1))
+pdmap[:,:,0] = phtmaps['PDmap16']
+
+phantom_info = {'T1': t1map, 'T2': t2map, 'PD': pdmap,
+                'dr': dr, 'fov': FOV, 'N': N, 'type': 'custom', 'dim': 2, 'dir': 'z', 'loc': 0}
+
+
+# Simulate
+print('Starting at: ', datetime.now())
+simulate_pulseq_jemris(seq_path=sps, phantom_info=phantom_info, sim_name=sim_name, coil_fov=0.25)
+kk, im, images = recon_jemris(file='sim/' + sim_name + '/signals.h5', dims=[n, n])
+savemat('sim/' + sim_name + '/utest_pulseq_sim_output.mat', {'images': images, 'kspace': kk, 'imspace': im})
+print('Ending at: ', datetime.now())

+ 38 - 0
libs/lf-scanner/py2jemris/sim_seq_validation.py

@@ -0,0 +1,38 @@
+import os
+from pulseq_jemris_simulator import simulate_pulseq_jemris, recon_jemris
+from scipy.io import savemat, loadmat
+
+
+# IRSE
+n = 32
+phantom_info = {'fov': 0.25, 'N': n, 'type': 'cylindrical', 'dim': 2, 'dir': 'z', 'loc': 0}
+sps = 'sim/seq_validation/irse_32/irse32.seq'
+sim_name = 'seq_validation\\irse_32'
+
+#Simulate
+simulate_pulseq_jemris(seq_path=sps, phantom_info=phantom_info, sim_name=sim_name, coil_fov=0.25)
+kk, im, images = recon_jemris(file='sim/' + sim_name + '/signals.h5', dims=[n, n])
+savemat('sim/' + sim_name + '/utest_pulseq_sim_output.mat', {'images': images, 'kspace': kk, 'imspace': im})
+
+
+# #
+# # TSE
+# n = 32
+# phantom_info = {'fov': 0.25, 'N': n, 'type': 'cylindrical', 'dim': 2, 'dir': 'z', 'loc': -0.08}
+# sps = 'sim/seq_validation/tse_32/tse32.seq'
+# sim_name = 'seq_validation\\tse_32'
+# # Make sequence
+# simulate_pulseq_jemris(seq_path=sps, phantom_info=phantom_info, sim_name=sim_name, coil_fov=0.25)
+# kk, im, images = recon_jemris(file='sim/' + sim_name + '/signals.h5', dims=[n, n])
+# savemat('sim/' + sim_name + '/TSE-T2PLANE-utest_pulseq_sim_output.mat', {'images': images, 'kspace': kk, 'imspace': im})
+#
+
+# ## DWI
+# n = 32
+# phantom_info = {'fov':0.25, 'N':n, 'type': 'cylindrical', 'dim': 2, 'dir': 'z', 'loc': -0.08}
+# sps = 'sim/seq_validation/dwi_32/dwi32.seq'
+# sim_name = 'seq_validation\\tse_32'
+#
+# simulate_pulseq_jemris(seq_path=sps, phantom_info=phantom_info, sim_name=sim_name, coil_fov=0.25)
+# kk, im, images = recon_jemris(file='sim/'+sim_name+'/signals.h5',dims=[n,n])
+# savemat('sim/'+ sim_name + '/dwi_pulseq_sim_output.mat',{'images':images, 'kspace':kk, 'imspace':im})

+ 181 - 0
libs/lf-scanner/py2jemris/utest_py2jemris_script.py

@@ -0,0 +1,181 @@
+# Demonstrates usage of py2jemris functionalities
+# May be used for quick testing
+# Gehua Tong
+# May 18, 2020
+
+from coil2xml import coil2xml
+from seq2xml import seq2xml
+from sim_jemris import sim_jemris
+from pulseq_jemris_simulator import simulate_pulseq_jemris, create_and_save_phantom
+from recon_jemris import recon_jemris
+import phantom as pht
+from pulseq_library import make_pulseq_irse, make_pulseq_se_oblique
+
+import numpy as np
+import matplotlib.pyplot as plt
+from pypulseq.Sequence.sequence import Sequence
+from scipy.io import loadmat, savemat
+
+
+#from virtualscanner.core.constants import SERVER_SIM_BLOCH_PY2JEMRIS_PATH
+
+import os
+import h5py
+
+
+utest_path = 'sim/utest_outputs'
+sim_path = 'sim'
+
+
+def utest_coil2xml():
+    # Example on using coil2xml
+    # Generate coil using B1 maps and plot
+    # 4 channels with different B1 maps
+
+    b1 = np.ones((32,32))
+    XY = np.meshgrid(np.linspace(0,1,32), np.linspace(0,1,32))
+    X = XY[0]
+    Y = XY[1]
+
+    # Define coil sensitivity maps (complex arrays, in general)
+    b1_ch1 = np.sqrt(X**2 + Y**2)
+    b1_ch2 = np.rot90(b1_ch1)
+    b1_ch3 = np.rot90(b1_ch2)
+    b1_ch4 = np.rot90(b1_ch3)
+
+    coil2xml(b1maps=[b1_ch1, b1_ch2, b1_ch3, b1_ch4], fov=200, name='test_coil', out_folder=utest_path)
+
+    # Generate sensmaps.h5 using JEMRIS command
+    os.chdir(utest_path)
+    print(os.system('dir'))
+    out = os.system('jemris test_coil.xml')
+    os.chdir('../..')
+    print(out)
+
+
+    # Load sensmaps.h5 and plot coil
+    a = h5py.File(utest_path + '/sensmaps.h5', 'r')
+    maps_magnitude = a['maps/magnitude']
+    maps_phase = a['maps/phase']
+    plt.figure(1)
+    plt.title('Coil sensitivity maps')
+    for u in range(4):
+        plt.subplot(2,4,u+1)
+        plt.gray()
+        plt.imshow(maps_magnitude[f'0{u}'])
+        plt.title(f'Magnitude Ch #{u+1}')
+        plt.subplot(2,4,u+5)
+        plt.gray()
+        plt.imshow(maps_phase[f'0{u}'])
+        plt.title(f'Phase Ch #{u+1}')
+    plt.show()
+    return
+
+def utest_seq2xml():
+    # Make a sequence
+    seq = make_pulseq_irse(fov=0.256, n=16, thk=0.01, fa=15, tr=150, te=30, ti=10,
+                           enc='xyz', slice_locs=None, write=False)
+
+    # Convert to .xml format
+    seq2xml(seq, seq_name='irse16_pulseq', out_folder=utest_path)
+
+
+
+    # Use JEMRIS to generate sequence diagrams from .xml sequence
+    os.chdir(utest_path)
+    print(os.system('dir'))
+    out = os.system(f'jemris -x -d id=1 -f irse16_pulseq irse16_pulseq.xml')
+    print(out)
+    os.chdir('../..')
+
+    # Read sequence diagram and plot
+    data = h5py.File(utest_path + '/irse16_pulseq.h5','r')
+    diag = data['seqdiag']
+
+    t = diag['T']
+    gx = diag['GX']
+    gy = diag['GY']
+    gz = diag['GZ']
+    rxp = diag['RXP']
+    txm = diag['TXM']
+    txp = diag['TXP']
+
+    ylist = [txm, txp, gx, gy, gz, rxp]
+    title_list = ['RF Tx magnitude', 'RF Tx phase', 'Gx', 'Gy', 'Gz', 'RF Rx phase']
+    styles = ['r-', 'g-', 'k-', 'k-', 'k-', 'bx']
+    plt.figure(1)
+    for v in range(6):
+        plt.subplot(6,1,v+1)
+        plt.plot(t, ylist[v], styles[v])
+        plt.title(title_list[v])
+        plt.xlabel('Time')
+
+    plt.show()
+
+
+    return
+
+def utest_sim_jemris():
+    # Copy helping files in
+    os.chdir(sim_path)
+    out = os.system('copy uniform.xml utest_outputs')
+    print(out)
+    os.chdir('..')
+
+    utest_phantom_output_h5()
+
+    list_sim_orig = {'seq_xml': 'gre32.xml', 'pht_h5': 'cylindrical.h5', 'tx_xml':'uniform.xml',
+                       'rx_xml': 'uniform.xml'}
+    out = sim_jemris(list_sim_orig, working_folder = utest_path)
+    os.chdir(utest_path)
+    savemat('data32_orig.mat',out)
+    print('Data is saved in py2jemris/sim/utest_outputs/data32_orig.mat')
+    os.chdir('../..')
+    return
+
+def utest_pulseq_sim():
+    # TODO this !
+    # Demonstrates simulation pipeline using pulseq inputs
+
+    # Define the same phantom
+    phantom_info = {'fov': 0.256, 'N': 15, 'type': 'cylindrical', 'dim': 2, 'dir': 'z', 'loc': 0}
+    sps =  'sim/utest_outputs/se_fov256mm_Nf15_Np15_TE50ms_TR200ms_FA90deg.seq'
+    sim_name = 'utest_outputs'
+    # Make sequence
+    os.chdir(utest_path)
+    make_pulseq_se_oblique(fov=0.256,n=15, thk=0.005, tr=0.2, te=0.05, fa=90,
+                              enc='xyz', slice_locs=[0], write=True)
+
+
+    os.chdir('../..')
+    simulate_pulseq_jemris(seq_path=sps, phantom_info=phantom_info, sim_name=sim_name,
+                           coil_fov=0.256)
+
+    kk, im, images = recon_jemris(file='sim/' + sim_name + '/signals.h5', dims=[15, 15])
+
+    savemat('sim/' + sim_name + '/utest_pulseq_sim_output.mat', {'images': images, 'kspace': kk, 'imspace': im})
+    print('Simulation result is in py2jemris/sim/utest_outputs/utest_pulseq_sim_output.mat')
+
+    # Plot results
+    plt.figure(1)
+    plt.gray()
+    plt.imshow(np.squeeze(images))
+    plt.show()
+
+    return
+
+def utest_phantom_output_h5():
+    # Creates a virtual scanner phantom and save it as an .h5 file (per JEMRIS standard)
+    phantom_info = {'fov': 0.256, 'N': 32, 'type': 'cylindrical', 'dim': 2, 'dir': 'z', 'loc': 0}
+    create_and_save_phantom(phantom_info, out_folder=utest_path)
+    return
+
+
+if __name__ == '__main__':
+
+    # Run all "utests"
+    #utest_coil2xml() # Converts B1 map into .h5 and .xml files for JEMRIS
+    #utest_phantom_output_h5() # Makes a virtual scanner phantom and converts it into .h5 format for JEMRIS
+    #utest_seq2xml() # Makes a pypulseq sequence and converts it into .xml and .h5 files for JEMRIS
+    #utest_sim_jemris() # Calls JEMRIS on command line using pre-made files
+    utest_pulseq_sim() # Calls pipeline (INPUT: seq + phantom info + FOV ; OUTPUT: complex image space & k-space, images)

BIN
libs/lf-scanner/pypulseq/SAR/QGlobal.mat


+ 325 - 0
libs/lf-scanner/pypulseq/SAR/SAR_calc.py

@@ -0,0 +1,325 @@
+# Copyright of the Board of Trustees of Columbia University in the City of New York
+from pathlib import Path
+from typing import Tuple
+from typing import Union
+
+import matplotlib.pyplot as plt
+import numpy as np
+import numpy.matlib
+import scipy.io as sio
+from scipy import interpolate
+
+from LF_scanner.pypulseq.Sequence.sequence import Sequence
+from LF_scanner.pypulseq.calc_duration import calc_duration
+
+
+def _calc_SAR(Q: np.ndarray, I: np.ndarray) -> np.ndarray:
+    """
+    Compute the SAR output for a given Q matrix and I current values.
+
+    Parameters
+    ----------
+    Q : numpy.ndarray
+        Q matrix. Refer Graesslin, Ingmar, et al. "A specific absorption rate prediction concept for parallel
+        transmission MR." Magnetic resonance in medicine 68.5 (2012): 1664-1674.
+    I : numpy.ndarray
+        I matrix, capturing the current (in Amps) on each of the transmit channels. Refer Graesslin, Ingmar, et al. "A
+        specific absorption rate prediction concept for parallel transmission MR." Magnetic resonance in medicine
+        68.5 (2012): 1664-1674.
+
+    Returns
+    -------
+    SAR : numpy.ndarray
+       Contains the SAR value for a particular Q matrix
+    """
+
+    if len(I.shape) == 1:  # Just to fit the multi-transmit case for now, TODO
+        I = np.tile(I, (Q.shape[0], 1))  # Nc x Nt
+
+    I_fact = np.divide(np.matmul(I, np.conjugate(I).T), I.shape[1])
+    SAR_temp = np.multiply(Q, I_fact)
+    SAR = np.abs(np.sum(SAR_temp[:]))
+
+    return SAR
+
+
+def _load_Q() -> Tuple[np.ndarray, np.ndarray]:
+    """
+    Load Q matrix that is precomputed based on the VHM model for 8 channels. Refer Graesslin, Ingmar, et al. "A
+    specific absorption rate prediction concept for parallel transmission MR." Magnetic resonance in medicine 68.5
+    (2012): 1664-1674.
+
+    Returns
+    -------
+    Qtmf, Qhmf : numpy.ndarray
+        Contains the Q-matrix of global SAR values for body-mass and head-mass respectively.
+    """
+    # Load relevant Q matrices computed from the model - this code will be integrated later - starting from E fields
+    path_Q = str(Path(__file__).parent / "QGlobal.mat")
+    Q = sio.loadmat(path_Q)
+    Q = Q["Q"]
+    val = Q[0, 0]
+
+    Qtmf = val["Qtmf"]
+    Qhmf = val["Qhmf"]
+    return Qtmf, Qhmf
+
+
+def _SAR_from_seq(
+    seq: Sequence, Qtmf: np.ndarray, Qhmf: np.ndarray
+) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+    """
+    Compute global whole body and head only SAR values for the given `seq` object.
+
+    Parameters
+    ----------
+    seq : Sequence
+        Sequence object to calculate for which SAR values will be calculated.
+    Qtmf : numpy.ndarray
+        Q-matrix of global SAR values for body-mass.
+    Qhmf : numpy.ndarray
+        Q-matrix of global SAR values for head-mass.
+
+    Returns
+    -------
+    SAR_wbg : numpy.ndarray
+        SAR values for body-mass.
+    SAR_hg : numpy.ndarray
+        SAR values for head-mass.
+    t : numpy.ndarray
+        Corresponding time points.
+    """
+    # Identify RF blocks and compute SAR - 10 seconds must be less than twice and 6 minutes must be less than
+    # 4 (WB) and 3.2 (head-20)
+    block_events = seq.block_events
+    num_events = len(block_events)
+    t = np.zeros(num_events)
+    SAR_wbg = np.zeros(t.shape)
+    SAR_hg = np.zeros(t.shape)
+    t_prev = 0
+
+    for block_counter in block_events:
+        block = seq.get_block(block_counter)
+        block_dur = calc_duration(block)
+        t[block_counter - 1] = t_prev + block_dur
+        t_prev = t[block_counter - 1]
+        if hasattr(block, "rf"):  # has rf
+            rf = block.rf
+            signal = rf.signal
+            # This rf could be parallel transmit as well
+            SAR_wbg[block_counter] = _calc_SAR(Qtmf, signal)
+            SAR_hg[block_counter] = _calc_SAR(Qhmf, signal)
+
+    return SAR_wbg, SAR_hg, t
+
+
+def _SAR_interp(SAR: np.ndarray, t: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
+    """
+    Interpolate SAR values for one second resolution.
+
+    Parameters
+    ----------
+    SAR : numpy.ndarray
+        SAR values
+    t : numpy.ndarray
+        Current time points.
+
+    Returns
+    -------
+    SAR_interp : numpy.ndarray
+        Interpolated values of SAR for a temporal resolution of 1 second.
+    t_sec : numpy.ndarray
+        Time points at 1 second resolution.
+    """
+    t_sec = np.arange(1, np.floor(t[-1]) + 1, 1)
+    f = interpolate.interp1d(t, SAR)
+    SAR_interp = f(t_sec)
+    return SAR_interp, t_sec
+
+
+def _SAR_lims_check(
+    SARwbg_lim_s, SARhg_lim_s, tsec
+) -> Tuple[
+    np.ndarray,
+    np.ndarray,
+    np.ndarray,
+    np.ndarray,
+    np.ndarray,
+    np.ndarray,
+    np.ndarray,
+    np.ndarray,
+]:
+    """
+    Check for SAR violations as compared to IEC 10 second and 6 minute averages;
+    returns SAR values that are interpolated for the fixed IEC time intervals.
+
+    Parameters
+    ----------
+    SARwbg_lim_s : numpy.ndarray
+    SARhg_lim_s : numpy.ndarray
+    tsec : numpy.ndarray
+
+    Returns
+    -------
+    SAR_wbg_tensec : numpy.ndarray
+    SAR_wbg_sixmin : numpy.ndarray
+    SAR_hg_tensec : numpy.ndarray
+    SAR_hg_sixmin : numpy.ndarray
+    SAR_wbg_sixmin_peak : numpy.ndarray
+    SAR_hg_sixmin_peak : numpy.ndarray
+    SAR_wbg_tensec_peak : numpy.ndarray
+    SAR_hg_tensec_peak : numpy.ndarray
+    """
+    if tsec[-1] > 10:
+        six_min_threshold_wbg = 4
+        ten_sec_threshold_wbg = 8
+
+        six_min_threshold_hg = 3.2
+        ten_sec_threshold_hg = 6.4
+
+        SAR_wbg_lim_app = np.concatenate(
+            (np.zeros(5), SARwbg_lim_s, np.zeros(5)), axis=0
+        )
+        SAR_hg_lim_app = np.concatenate((np.zeros(5), SARhg_lim_s, np.zeros(5)), axis=0)
+
+        SAR_wbg_tensec = _do_sw_sar(SAR_wbg_lim_app, tsec, 10)  # < 2  SARmax
+        SAR_hg_tensec = _do_sw_sar(SAR_hg_lim_app, tsec, 10)  # < 2 SARmax
+        SAR_wbg_tensec_peak = np.round(np.max(SAR_wbg_tensec), 2)
+        SAR_hg_tensec_peak = np.round(np.max(SAR_hg_tensec), 2)
+
+        if (np.max(SAR_wbg_tensec) > ten_sec_threshold_wbg) or (
+            np.max(SAR_hg_tensec) > ten_sec_threshold_hg
+        ):
+            print("Pulse exceeding 10 second Global SAR limits, increase TR")
+        SAR_wbg_sixmin = "NA"
+        SAR_hg_sixmin = "NA"
+        SAR_wbg_sixmin_peak = "NA"
+        SAR_hg_sixmin_peak = "NA"
+
+        if tsec[-1] > 600:
+            SAR_wbg_lim_app = np.concatenate(
+                (np.zeros(300), SARwbg_lim_s, np.zeros(300)), axis=0
+            )
+            SAR_hg_lim_app = np.concatenate(
+                (np.zeros(300), SARhg_lim_s, np.zeros(300)), axis=0
+            )
+
+            SAR_hg_sixmin = _do_sw_sar(SAR_hg_lim_app, tsec, 600)
+            SAR_wbg_sixmin = _do_sw_sar(SAR_wbg_lim_app, tsec, 600)
+            SAR_wbg_sixmin_peak = np.round(np.max(SAR_wbg_sixmin), 2)
+            SAR_hg_sixmin_peak = np.round(np.max(SAR_hg_sixmin), 2)
+
+            if (np.max(SAR_hg_sixmin) > six_min_threshold_wbg) or (
+                np.max(SAR_hg_sixmin) > six_min_threshold_hg
+            ):
+                print("Pulse exceeding 10 second Global SAR limits, increase TR")
+    else:
+        print("Need at least 10 seconds worth of sequence to calculate SAR")
+        SAR_wbg_tensec = "NA"
+        SAR_wbg_sixmin = "NA"
+        SAR_hg_tensec = "NA"
+        SAR_hg_sixmin = "NA"
+        SAR_wbg_sixmin_peak = "NA"
+        SAR_hg_sixmin_peak = "NA"
+        SAR_wbg_tensec_peak = "NA"
+        SAR_hg_tensec_peak = "NA"
+
+    return (
+        SAR_wbg_tensec,
+        SAR_wbg_sixmin,
+        SAR_hg_tensec,
+        SAR_hg_sixmin,
+        SAR_wbg_sixmin_peak,
+        SAR_hg_sixmin_peak,
+        SAR_wbg_tensec_peak,
+        SAR_hg_tensec_peak,
+    )
+
+
+def _do_sw_sar(SAR: np.ndarray, tsec: np.ndarray, t: np.ndarray) -> np.ndarray:
+    """
+    Compute a sliding window average of SAR values.
+
+    Parameters
+    ----------
+    SAR : numpy.ndarray
+        SAR values.
+    tsec : numpy.ndarray
+        Corresponding time points at 1 second resolution.
+    t : numpy.ndarray
+        Corresponding time points.
+
+    Returns
+    -------
+    SAR_timeavag : numpy.ndarray
+        Sliding window time average of SAR values.
+    """
+    SAR_time_avg = np.zeros(len(tsec) + int(t))
+    for instant in range(
+        int(t / 2), int(t / 2) + (int(tsec[-1]))
+    ):  # better to go from  -sw / 2: sw / 2
+        SAR_time_avg[instant] = (
+            sum(SAR[range(instant - int(t / 2), instant + int(t / 2) - 1)]) / t
+        )
+    SAR_time_avg = SAR_time_avg[int(t / 2) : int(t / 2) + (int(tsec[-1]))]
+    return SAR_time_avg
+
+
+def calc_SAR(file: Union[str, Path, Sequence]) -> None:
+    """
+    Compute Global SAR values on the `.seq` object for head and whole body over the specified time averages.
+
+    Parameters
+    ----------
+    file : str, Path or Seuqence
+        `.seq` file for which global SAR values will be computed. Can be path to `.seq` file as `str` or `Path`, or the
+        `Sequence` object itself.
+
+    Raises
+    ------
+    ValueError
+        If `file` is a `str` or `Path` to the `.seq` file and this file does not exist on disk.
+    """
+    if isinstance(file, (str, Path)):
+        if isinstance(file, str):
+            file = Path(file)
+
+        if file.exists() and file.is_file():
+            seq_obj = Sequence()
+            seq_obj.read(str(file))
+            seq_obj = seq_obj
+        else:
+            raise ValueError("Seq file does not exist.")
+    else:
+        seq_obj = file
+
+    Q_tmf, Q_hmf = _load_Q()
+    SAR_wbg, SAR_hg, t = _SAR_from_seq(seq_obj, Q_tmf, Q_hmf)
+    SARwbg_lim, tsec = _SAR_interp(SAR_wbg, t)
+    SARhg_lim, tsec = _SAR_interp(SAR_hg, t)
+    (
+        SAR_wbg_tensec,
+        SAR_wbg_sixmin,
+        SAR_hg_tensec,
+        SAR_hg_sixmin,
+        SAR_wbg_sixmin_peak,
+        SAR_hg_sixmin_peak,
+        SAR_wbg_tensec_peak,
+        SAR_hg_tensec_peak,
+    ) = _SAR_lims_check(SARwbg_lim, SARhg_lim, tsec)
+
+    # Plot 10 sec average SAR
+    if tsec[-1] > 10:
+        plt.plot(tsec, SAR_wbg_tensec, "x-", label="Whole Body: 10sec")
+        plt.plot(tsec, SAR_hg_tensec, ".-", label="Head only: 10sec")
+
+        # plt.plot(t, SARwbg, label='Whole Body - instant')
+        # plt.plot(t, SARhg, label='Whole Body - instant')
+
+        plt.xlabel("Time (s)")
+        plt.ylabel("SAR (W/kg)")
+        plt.title("Global SAR  - Mass Normalized -  Whole body and head only")
+
+        plt.legend()
+        plt.grid(True)
+        plt.show()

+ 0 - 0
libs/lf-scanner/pypulseq/SAR/__init__.py


+ 0 - 0
libs/lf-scanner/pypulseq/Sequence/__init__.py


+ 637 - 0
libs/lf-scanner/pypulseq/Sequence/block.py

@@ -0,0 +1,637 @@
+from types import SimpleNamespace
+from typing import Tuple, List, Union
+
+import numpy as np
+
+from LF_scanner.pypulseq.block_to_events import block_to_events
+from LF_scanner.pypulseq.compress_shape import compress_shape
+from LF_scanner.pypulseq.decompress_shape import decompress_shape
+from LF_scanner.pypulseq.event_lib import EventLibrary
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_labels
+
+
+def set_block(self, block_index: int, *args: SimpleNamespace) -> None:
+    """
+    Replace block at index with new block provided as block structure, add sequence block, or create a new block
+    from events and store at position specified by index. The block or events are provided in uncompressed form and
+    will be stored in the compressed, non-redundant internal libraries.
+
+    See also:
+    - `pypulseq.Sequence.sequence.Sequence.get_block()`
+    - `pypulseq.Sequence.sequence.Sequence.add_block()`
+
+    Parameters
+    ----------
+    block_index : int
+        Index at which block is replaced.
+    args : SimpleNamespace
+        Block or events to be replaced/added or created at `block_index`.
+
+    Raises
+    ------
+    ValueError
+        If trigger event that is passed is of unsupported control event type.
+        If delay is set for a gradient even that starts with a non-zero amplitude.
+    RuntimeError
+        If two consecutive gradients to not have the same amplitude at the connection point.
+        If the first gradient in the block does not start with 0.
+        If a gradient that doesn't end at zero is not aligned to the block boundary.
+    """
+    events = block_to_events(*args)
+    self.block_events[block_index] = np.zeros(7, dtype=np.int32)
+    duration = 0
+
+    check_g = {}  # Key-value mapping of index and  pairs of gradients/times
+    extensions = []
+
+    for event in events:
+        if not isinstance(event, float):  # If event is not a block duration
+            if event.type == "rf":
+                if hasattr(event, "id"):
+                    rf_id = event.id
+                else:
+                    rf_id, _ = register_rf_event(self, event)
+
+                self.block_events[block_index][1] = rf_id
+                duration = max(
+                    duration, event.shape_dur + event.delay + event.ringdown_time
+                )
+            elif event.type == "grad":
+                channel_num = ["x", "y", "z"].index(event.channel)
+                idx = 2 + channel_num
+
+                grad_start = (
+                    event.delay
+                    + np.floor(event.tt[0] / self.grad_raster_time + 1e-10)
+                    * self.grad_raster_time
+                )
+                grad_duration = (
+                    event.delay
+                    + np.ceil(event.tt[-1] / self.grad_raster_time - 1e-10)
+                    * self.grad_raster_time
+                )
+
+                check_g[channel_num] = SimpleNamespace()
+                check_g[channel_num].idx = idx
+                check_g[channel_num].start = np.array((grad_start, event.first))
+                check_g[channel_num].stop = np.array((grad_duration, event.last))
+
+                if hasattr(event, "id"):
+                    grad_id = event.id
+                else:
+                    grad_id, _ = register_grad_event(self, event)
+
+                self.block_events[block_index][idx] = grad_id
+                duration = np.max([duration, grad_duration])
+            elif event.type == "trap":
+                channel_num = ["x", "y", "z"].index(event.channel)
+                idx = 2 + channel_num
+
+                check_g[channel_num] = SimpleNamespace()
+                check_g[channel_num].idx = idx
+                check_g[channel_num].start = np.array((0, 0))
+                check_g[channel_num].stop = np.array(
+                    (
+                        event.delay
+                        + event.rise_time
+                        + event.fall_time
+                        + event.flat_time,
+                        0,
+                    )
+                )
+
+                if hasattr(event, "id"):
+                    trap_id = event.id
+                else:
+                    trap_id = register_grad_event(self, event)
+
+                self.block_events[block_index][idx] = trap_id
+                duration = np.max(
+                    [
+                        duration,
+                        event.delay
+                        + event.rise_time
+                        + event.flat_time
+                        + event.fall_time,
+                    ]
+                )
+            elif event.type == "adc":
+                if hasattr(event, "id"):
+                    adc_id = event.id
+                else:
+                    adc_id = register_adc_event(self, event)
+
+                self.block_events[block_index][5] = adc_id
+                duration = np.max(
+                    [
+                        duration,
+                        event.delay + event.num_samples * event.dwell + event.dead_time,
+                    ]
+                )
+            elif event.type == "delay":
+                duration = np.max([duration, event.delay])
+            elif event.type in ["output", "trigger"]:
+                if hasattr(event, "id"):
+                    event_id = event.id
+                else:
+                    event_id = register_control_event(self, event)
+
+                ext = {"type": self.get_extension_type_ID("TRIGGERS"), "ref": event_id}
+                extensions.append(ext)
+                duration = np.max([duration, event.delay + event.duration])
+            elif event.type in ["labelset", "labelinc"]:
+                if hasattr(event, "id"):
+                    label_id = event.id
+                else:
+                    label_id = register_label_event(self, event)
+
+                ext = {
+                    "type": self.get_extension_type_ID(event.type.upper()),
+                    "ref": label_id,
+                }
+                extensions.append(ext)
+
+    # =========
+    # ADD EXTENSIONS
+    # =========
+    if len(extensions) > 0:
+        """
+        Add extensions now... but it's tricky actually we need to check whether the exactly the same list of extensions
+        already exists, otherwise we have to create a new one... ooops, we have a potential problem with the key
+        mapping then... The trick is that we rely on the sorting of the extension IDs and then we can always find the
+        last one in the list by setting the reference to the next to 0 and then proceed with the other elements.
+        """
+        sort_idx = np.argsort([e["ref"] for e in extensions])
+        extensions = np.take(extensions, sort_idx)
+        all_found = True
+        extension_id = 0
+        for i in range(len(extensions)):
+            data = [extensions[i]["type"], extensions[i]["ref"], extension_id]
+            extension_id, found = self.extensions_library.find(data)
+            all_found = all_found and found
+            if not found:
+                break
+
+        if not all_found:
+            # Add the list
+            extension_id = 0
+            for i in range(len(extensions)):
+                data = [extensions[i]["type"], extensions[i]["ref"], extension_id]
+                extension_id, found = self.extensions_library.find(data)
+                if not found:
+                    self.extensions_library.insert(extension_id, data)
+
+        # Now we add the ID
+        self.block_events[block_index][6] = extension_id
+
+    # =========
+    # PERFORM GRADIENT CHECKS
+    # =========
+    for grad_to_check in check_g.values():
+
+        if (
+            abs(grad_to_check.start[1])
+            > self.system.max_slew * self.system.grad_raster_time
+        ):
+            if grad_to_check.start[0] != 0:
+                raise ValueError(
+                    "No delay allowed for gradients which start with a non-zero amplitude"
+                )
+
+            if block_index > 1:
+                prev_id = self.block_events[block_index - 1][grad_to_check.idx]
+                if prev_id != 0:
+                    prev_lib = self.grad_library.get(prev_id)
+                    prev_data = prev_lib["data"]
+                    prev_type = prev_lib["type"]
+                    if prev_type == "t":
+                        raise RuntimeError(
+                            "Two consecutive gradients need to have the same amplitude at the connection point"
+                        )
+                    elif prev_type == "g":
+                        last = prev_data[5]
+                        if (
+                            abs(last - grad_to_check.start[1])
+                            > self.system.max_slew * self.system.grad_raster_time
+                        ):
+                            raise RuntimeError(
+                                "Two consecutive gradients need to have the same amplitude at the connection point"
+                            )
+            else:
+                raise RuntimeError(
+                    "First gradient in the the first block has to start at 0."
+                )
+
+        if (
+            grad_to_check.stop[1] > self.system.max_slew * self.system.grad_raster_time
+            and abs(grad_to_check.stop[0] - duration) > 1e-7
+        ):
+            raise RuntimeError(
+                "A gradient that doesn't end at zero needs to be aligned to the block boundary."
+            )
+
+    self.block_durations[block_index] = float(duration)
+
+
+def get_block(self, block_index: int) -> SimpleNamespace:
+    """
+    Returns PyPulseq block at `block_index` position in `self.block_events`.
+
+    The block is created from the sequence data with all events and shapes decompressed.
+
+    Parameters
+    ----------
+    block_index : int
+        Index of PyPulseq block to be retrieved from `self.block_events`.
+
+    Returns
+    -------
+    block : SimpleNamespace
+        PyPulseq block at 'block_index' position in `self.block_events`.
+
+    Raises
+    ------
+    ValueError
+        If a trigger event of an unsupported control type is encountered.
+        If a label object of an unknown extension ID is encountered.
+    """
+
+    block = SimpleNamespace()
+    attrs = ["block_duration", "rf", "gx", "gy", "gz", "adc"]
+    values = [None] * len(attrs)
+    for att, val in zip(attrs, values):
+        setattr(block, att, val)
+    event_ind = self.block_events[block_index]
+
+    if event_ind[0] > 0:  # Delay
+        delay = SimpleNamespace()
+        delay.type = "delay"
+        delay.delay = self.delay_library.data[event_ind[0]][0]
+        block.delay = delay
+
+    if event_ind[1] > 0:  # RF
+        if len(self.rf_library.type) >= event_ind[1]:
+            block.rf = self.rf_from_lib_data(
+                self.rf_library.data[event_ind[1]], self.rf_library.type[event_ind[1]]
+            )
+        else:
+            block.rf = self.rf_from_lib_data(
+                self.rf_library.data[event_ind[1]]
+            )  # Undefined type/use
+
+    # Gradients
+    grad_channels = ["gx", "gy", "gz"]
+    for i in range(len(grad_channels)):
+        if event_ind[2 + i] > 0:
+            grad, compressed = SimpleNamespace(), SimpleNamespace()
+            grad_type = self.grad_library.type[event_ind[2 + i]]
+            lib_data = self.grad_library.data[event_ind[2 + i]]
+            grad.type = "trap" if grad_type == "t" else "grad"
+            grad.channel = grad_channels[i][1]
+            if grad.type == "grad":
+                amplitude = lib_data[0]
+                shape_id = lib_data[1]
+                time_id = lib_data[2]
+                delay = lib_data[3]
+                shape_data = self.shape_library.data[shape_id]
+                compressed.num_samples = shape_data[0]
+                compressed.data = shape_data[1:]
+                g = decompress_shape(compressed)
+                grad.waveform = amplitude * g
+
+                if time_id == 0:
+                    grad.tt = (np.arange(1, len(g) + 1) - 0.5) * self.grad_raster_time
+                    t_end = len(g) * self.grad_raster_time
+                else:
+                    t_shape_data = self.shape_library.data[time_id]
+                    compressed.num_samples = t_shape_data[0]
+                    compressed.data = t_shape_data[1:]
+                    grad.tt = decompress_shape(compressed) * self.grad_raster_time
+
+                    assert len(grad.waveform) == len(grad.tt)
+                    t_end = grad.tt[-1]
+
+                grad.shape_id = shape_id
+                grad.time_id = time_id
+                grad.delay = delay
+                grad.shape_dur = t_end
+                if len(lib_data) > 5:
+                    grad.first = lib_data[4]
+                    grad.last = lib_data[5]
+            else:
+                grad.amplitude = lib_data[0]
+                grad.rise_time = lib_data[1]
+                grad.flat_time = lib_data[2]
+                grad.fall_time = lib_data[3]
+                grad.delay = lib_data[4]
+                grad.area = grad.amplitude * (
+                    grad.flat_time + grad.rise_time / 2 + grad.fall_time / 2
+                )
+                grad.flat_area = grad.amplitude * grad.flat_time
+
+            setattr(block, grad_channels[i], grad)
+
+    # ADC
+    if event_ind[5] > 0:
+        lib_data = self.adc_library.data[event_ind[5]]
+        if len(lib_data) < 6:
+            lib_data = np.append(lib_data, 0)
+
+        adc = SimpleNamespace()
+        (
+            adc.num_samples,
+            adc.dwell,
+            adc.delay,
+            adc.freq_offset,
+            adc.phase_offset,
+            adc.dead_time,
+        ) = [lib_data[x] for x in range(6)]
+        adc.num_samples = int(adc.num_samples)
+        adc.type = "adc"
+        block.adc = adc
+
+    # Triggers
+    if event_ind[6] > 0:
+        # We have extensions - triggers, labels, etc.
+        next_ext_id = event_ind[6]
+        while next_ext_id != 0:
+            ext_data = self.extensions_library.data[next_ext_id]
+            # Format: ext_type, ext_id, next_ext_id
+            ext_type = self.get_extension_type_string(ext_data[0])
+
+            if ext_type == "TRIGGERS":
+                trigger_types = ["output", "trigger"]
+                data = self.trigger_library.data[ext_data[1]]
+                trigger = SimpleNamespace()
+                trigger.type = trigger_types[int(data[0]) - 1]
+                if data[0] == 1:
+                    trigger_channels = ["osc0", "osc1", "ext1"]
+                    trigger.channel = trigger_channels[int(data[1]) - 1]
+                elif data[0] == 2:
+                    trigger_channels = ["physio1", "physio2"]
+                    trigger.channel = trigger_channels[int(data[1]) - 1]
+                else:
+                    raise ValueError("Unsupported trigger event type")
+
+                trigger.delay = data[2]
+                trigger.duration = data[3]
+                # Allow for multiple triggers per block
+                if hasattr(block, "trigger"):
+                    block.trigger[len(block.trigger)] = trigger
+                else:
+                    block.trigger = {0: trigger}
+            elif ext_type in ["LABELSET", "LABELINC"]:
+                label = SimpleNamespace()
+                label.type = ext_type.lower()
+                supported_labels = get_supported_labels()
+                if ext_type == "LABELSET":
+                    data = self.label_set_library.data[ext_data[1]]
+                else:
+                    data = self.label_inc_library.data[ext_data[1]]
+
+                label.label = supported_labels[int(data[1] - 1)]
+                label.value = data[0]
+                # Allow for multiple labels per block
+                if hasattr(block, "label"):
+                    block.label[len(block.label)] = label
+                else:
+                    block.label = {0: label}
+            else:
+                raise RuntimeError(f"Unknown extension ID {ext_data[0]}")
+
+            next_ext_id = ext_data[2]
+
+    block.block_duration = self.block_durations[block_index]
+
+    return block
+
+
+def register_adc_event(self, event: EventLibrary) -> int:
+    """
+
+    Parameters
+    ----------
+    event : SimpleNamespace
+        ADC event to be registered.
+
+    Returns
+    -------
+    int
+        ID of registered ADC event.
+    """
+    data = np.array(
+        [
+            event.num_samples,
+            event.dwell,
+            np.max([event.delay, event.dead_time]),
+            event.freq_offset,
+            event.phase_offset,
+            event.dead_time,
+        ]
+    )
+    adc_id, _ = self.adc_library.find_or_insert(new_data=data)
+
+    return adc_id
+
+
+def register_control_event(self, event: SimpleNamespace) -> int:
+    """
+
+    Parameters
+    ----------
+    event : SimpleNamespace
+        Control event to be registered.
+
+    Returns
+    -------
+    int
+        ID of registered control event.
+    """
+    event_type = ["output", "trigger"].index(event.type)
+    if event_type == 0:
+        # Trigger codes supported by the Siemens interpreter as of May 2019
+        event_channel = ["osc0", "osc1", "ext1"].index(event.channel)
+    elif event_type == 1:
+        # Trigger codes supported by the Siemens interpreter as of June 2019
+        event_channel = ["physio1", "physio2"].index(event.channel)
+    else:
+        raise ValueError("Unsupported control event type")
+
+    data = [event_type + 1, event_channel + 1, event.delay, event.duration]
+    control_id, _ = self.trigger_library.find_or_insert(new_data=data)
+
+    return control_id
+
+
+def register_grad_event(
+    self, event: SimpleNamespace
+) -> Union[int, Tuple[int, List[int]]]:
+    """
+    Parameters
+    ----------
+    event : SimpleNamespace
+        Gradient event to be registered.
+
+    Returns
+    -------
+    int, [int, ...]
+        For gradient events: ID of registered gradient event, list of shape IDs
+    int
+        For trapezoid gradient events: ID of registered gradient event
+    """
+    may_exist = True
+    if event.type == "grad":
+        amplitude = np.abs(event.waveform).max()
+        if amplitude > 0:
+            fnz = event.waveform[np.nonzero(event.waveform)[0][0]]
+            amplitude *= (
+                np.sign(fnz) if fnz != 0 else 1
+            )  # Workaround for np.sign(0) = 0
+
+        if hasattr(event, "shape_IDs"):
+            shape_IDs = event.shape_IDs
+        else:
+            shape_IDs = [0, 0]
+            if amplitude != 0:
+                g = event.waveform / amplitude
+            else:
+                g = event.waveform
+            c_shape = compress_shape(g)
+            s_data = np.insert(c_shape.data, 0, c_shape.num_samples)
+            shape_IDs[0], found = self.shape_library.find_or_insert(s_data)
+            may_exist = may_exist & found
+            c_time = compress_shape(event.tt / self.grad_raster_time)
+
+            if not (
+                len(c_time.data) == 4
+                and np.all(c_time.data == [0.5, 1, 1, c_time.num_samples - 3])
+            ):
+                t_data = np.insert(c_time.data, 0, c_time.num_samples)
+                shape_IDs[1], found = self.shape_library.find_or_insert(t_data)
+                may_exist = may_exist & found
+
+        data = [amplitude, *shape_IDs, event.delay, event.first, event.last]
+    elif event.type == "trap":
+        data = np.array(
+            [
+                event.amplitude,
+                event.rise_time,
+                event.flat_time,
+                event.fall_time,
+                event.delay,
+            ]
+        )
+    else:
+        raise ValueError("Unknown gradient type passed to register_grad_event()")
+
+    if may_exist:
+        grad_id, _ = self.grad_library.find_or_insert(
+            new_data=data, data_type=event.type[0]
+        )
+    else:
+        grad_id = self.grad_library.insert(0, data, event.type[0])
+
+    if event.type == "grad":
+        return grad_id, shape_IDs
+    elif event.type == "trap":
+        return grad_id
+
+
+def register_label_event(self, event: SimpleNamespace) -> int:
+    """
+    Parameters
+    ----------
+    event : SimpleNamespace
+        ID of label event to be registered.
+
+    Returns
+    -------
+    int
+        ID of registered label event.
+    """
+
+    label_id = get_supported_labels().index(event.label) + 1
+    data = [event.value, label_id]
+    if event.type == "labelset":
+        label_id, _ = self.label_set_library.find_or_insert(new_data=data)
+    elif event.type == "labelinc":
+        label_id, _ = self.label_inc_library.find_or_insert(new_data=data)
+    else:
+        raise ValueError("Unsupported label type passed to register_label_event()")
+
+    return label_id
+
+
+def register_rf_event(self, event: SimpleNamespace) -> Tuple[int, List[int]]:
+    """
+    Parameters
+    ----------
+    event : SimpleNamespace
+        RF event to be registered.
+
+    Returns
+    -------
+    int, [int, ...]
+        ID of registered RF event, list of shape IDs
+    """
+    mag = np.abs(event.signal)
+    amplitude = np.max(mag)
+    mag /= amplitude
+    # Following line of code is a workaround for numpy's divide functions returning NaN when mathematical
+    # edge cases are encountered (eg. divide by 0)
+    mag[np.isnan(mag)] = 0
+    phase = np.angle(event.signal)
+    phase[phase < 0] += 2 * np.pi
+    phase /= 2 * np.pi
+    may_exist = True
+
+    if hasattr(event, "shape_IDs"):
+        shape_IDs = event.shape_IDs
+    else:
+        shape_IDs = [0, 0, 0]
+
+        mag_shape = compress_shape(mag)
+        data = np.insert(mag_shape.data, 0, mag_shape.num_samples)
+        shape_IDs[0], found = self.shape_library.find_or_insert(data)
+        may_exist = may_exist & found
+
+        phase_shape = compress_shape(phase)
+        data = np.insert(phase_shape.data, 0, phase_shape.num_samples)
+        shape_IDs[1], found = self.shape_library.find_or_insert(data)
+        may_exist = may_exist & found
+
+        time_shape = compress_shape(
+            event.t / self.rf_raster_time
+        )  # Time shape is stored in units of RF raster
+        if len(time_shape.data) == 4 and np.all(
+            time_shape.data == [0.5, 1, 1, time_shape.num_samples - 3]
+        ):
+            shape_IDs[2] = 0
+        else:
+            data = [time_shape.num_samples, *time_shape.data]
+            shape_IDs[2], found = self.shape_library.find_or_insert(data)
+            may_exist = may_exist & found
+
+    use = "u"  # Undefined
+    if hasattr(event, "use"):
+        if event.use in [
+            "excitation",
+            "refocusing",
+            "inversion",
+            "saturation",
+            "preparation",
+        ]:
+            use = event.use[0]
+        else:
+            use = "u"
+
+    data = np.array(
+        [amplitude, *shape_IDs, event.delay, event.freq_offset, event.phase_offset]
+    )
+
+    if may_exist:
+        rf_id, _ = self.rf_library.find_or_insert(new_data=data, data_type=use)
+    else:
+        rf_id = self.rf_library.insert(key_id=0, new_data=data, data_type=use)
+
+    return rf_id, shape_IDs

+ 179 - 0
libs/lf-scanner/pypulseq/Sequence/calc_grad_spectrum.py

@@ -0,0 +1,179 @@
+from typing import Tuple, List, Union
+
+import numpy as np
+from scipy.signal import spectrogram
+from matplotlib import pyplot as plt
+
+
+def calculate_gradient_spectrum(
+        obj,
+        max_frequency: float = 2000,
+        window_width: float = 0.05,
+        frequency_oversampling: float = 3,
+        time_range: Union[List[float], None] = None,
+        plot: bool = True,
+        combine_mode: str = 'max',
+        use_derivative: bool = False,
+        acoustic_resonances: List[dict] = [],
+) -> Tuple[List[np.ndarray], np.ndarray, np.ndarray, np.ndarray]:
+    """
+    Calculates the gradient spectrum of the sequence. Returns a spectrogram
+    for each gradient channel, as well as a root-sum-squares combined
+    spectrogram.
+    
+    Works by splitting the sequence into windows that are 'window_width'
+    long and calculating the fourier transform of each window. Windows
+    overlap 50% with the previous and next window. When 'combine_mode' is
+    not 'none', all windows are combined into one spectrogram.
+
+    Parameters
+    ----------
+    max_frequency : float, optional
+        Maximum frequency to include in spectrograms. The default is 2000.
+    window_width : float, optional
+        Window width (in seconds). The default is 0.05.
+    frequency_oversampling : float, optional
+        Oversampling in the frequency dimension, higher values make
+        smoother spectrograms. The default is 3.
+    time_range : List[float], optional
+        Time range over which to calculate the spectrograms as a list of
+        two timepoints (in seconds) (e.g. [1, 1.5])
+        The default is None.
+    plot : bool, optional
+        Whether to plot the spectograms. The default is True.
+    combine_mode : str, optional
+        How to combine all windows into one spectrogram, options:
+            'max', 'mean', 'rss' (root-sum-of-squares), 'none' (no combination)
+        The default is 'max'.
+    use_derivative : bool, optional
+        Whether the use the derivative of the gradient waveforms instead of the
+        gradient waveforms for the gradient spectrum calculations. The default
+        is False
+    acoustic_resonances : List[dict], optional
+        Acoustic resonances as a list of dictionaries with 'frequency' and
+        'bandwidth' elements. Only used when plot==True. The default is [].
+
+    Returns
+    -------
+    spectrograms : List[np.ndarray]
+        List of spectrograms per gradient channel.
+    spectrogram_rss : np.ndarray
+        Root-sum-of-squares combined spectrogram over all gradient channels.
+    frequencies : np.ndarray
+        Frequency axis of the spectrograms.
+    times : np.ndarray
+        Time axis of the spectrograms (only relevant when combine_mode == 'none').
+
+    """
+    dt = obj.system.grad_raster_time # time raster
+    nwin = round(window_width / dt)
+    nfft = round(frequency_oversampling*nwin)
+
+    # Get gradients as piecewise-polynomials
+    gw_pp = obj.get_gradients(time_range=time_range)
+    ng = len(gw_pp)
+    max_t = max(g.x[-1] for g in gw_pp if g is not None)
+    
+    # Determine sampling points
+    if time_range == None:
+        nt = int(np.ceil(max_t/dt))
+        t = (np.arange(nt) + 0.5)*dt
+    else:
+        tmax = min(time_range[1], max_t) - max(time_range[0], 0)
+        nt = int(np.ceil(tmax/dt))
+        t = max(time_range[0], 0) + (np.arange(nt) + 0.5)*dt
+    
+    # Sample gradients
+    gw = np.zeros((ng,t.shape[0]))
+    for i in range(ng):
+        if gw_pp[i] != None:
+            gw[i] = gw_pp[i](t)
+    
+    if use_derivative:
+        gw = np.diff(gw, axis=1)
+    
+    # Calculate spectrogram for each gradient channel
+    spectrograms: List[np.ndarray] = []
+    spectrogram_rss = 0
+    
+    for i in range(ng):
+        # Use scipy to calculate the spectrograms
+        freq, times, sxx = spectrogram(gw[i],
+                                       fs=1/dt,
+                                       mode='magnitude',
+                                       nperseg=nwin,
+                                       noverlap=nwin//2,
+                                       nfft=nfft,
+                                       detrend='constant',
+                                       window=('tukey', 1))
+        mask = freq<max_frequency
+        
+        # Accumulate spectrum for all gradient channels
+        spectrogram_rss += sxx[mask]**2
+        
+        # Combine spectrogram over time axis
+        if combine_mode == 'max':
+            s = sxx[mask].max(axis=1)
+        elif combine_mode == 'mean':
+            s = sxx[mask].mean(axis=1)
+        elif combine_mode == 'rss':
+            s = np.sqrt((sxx[mask]**2).sum(axis=1))
+        elif combine_mode == 'none':
+            s = sxx[mask]
+        else:
+            raise ValueError(f'Unknown value for combine_mode: {combine_mode}, must be one of [max, mean, rss, none]')
+        
+        frequencies = freq[mask]
+        spectrograms.append(s)
+    
+    # Root-sum-of-squares combined spectrogram for all gradient channels
+    spectrogram_rss = np.sqrt(spectrogram_rss)
+    if combine_mode == 'max':
+        spectrogram_rss = spectrogram_rss.max(axis=1)
+    elif combine_mode == 'mean':
+        spectrogram_rss = spectrogram_rss.mean(axis=1)
+    elif combine_mode == 'rss':
+        spectrogram_rss = np.sqrt((spectrogram_rss**2).sum(axis=1))
+    
+    # Plot spectrograms and acoustic resonances if specified
+    if plot:
+        if combine_mode != 'none':
+            plt.figure()
+            plt.xlabel('Frequency (Hz)')
+            # According to spectrogram documentation y unit is (Hz/m)^2 / Hz = Hz/m^2, is this meaningful?
+            for s in spectrograms:
+                plt.plot(frequencies, s)
+            plt.plot(frequencies, spectrogram_rss)
+            plt.legend(['x', 'y', 'z', 'rss'])
+    
+            for res in acoustic_resonances:
+                plt.axvline(res['frequency'], color='k', linestyle='-')
+                plt.axvline(res['frequency'] - res['bandwidth']/2, color='k', linestyle='--')
+                plt.axvline(res['frequency'] + res['bandwidth']/2, color='k', linestyle='--')
+        else:
+            for s, c in zip(spectrograms, ['X', 'Y', 'Z']):
+                plt.figure()
+                plt.title(f'Spectrum {c}')
+                plt.xlabel('Time (s)')
+                plt.ylabel('Frequency (Hz)')
+                plt.imshow(abs(s[::-1]), extent=(times[0], times[-1], frequencies[0], frequencies[-1]),
+                           aspect=(times[-1]-times[0])/(frequencies[-1]-frequencies[0]))
+                
+                for res in acoustic_resonances:
+                    plt.axhline(res['frequency'], color='r', linestyle='-')
+                    plt.axhline(res['frequency'] - res['bandwidth']/2, color='r', linestyle='--')
+                    plt.axhline(res['frequency'] + res['bandwidth']/2, color='r', linestyle='--')
+            
+            plt.figure()
+            plt.title('Total spectrum')
+            plt.xlabel('Time (s)')
+            plt.ylabel('Frequency (Hz)')
+            plt.imshow(abs(spectrogram_rss[::-1]), extent=(times[0], times[-1], frequencies[0], frequencies[-1]),
+                       aspect=(times[-1]-times[0])/(frequencies[-1]-frequencies[0]))
+            
+            for res in acoustic_resonances:
+                plt.axhline(res['frequency'], color='r', linestyle='-')
+                plt.axhline(res['frequency'] - res['bandwidth']/2, color='r', linestyle='--')
+                plt.axhline(res['frequency'] + res['bandwidth']/2, color='r', linestyle='--')
+                
+    return spectrograms, spectrogram_rss, frequencies, times

+ 102 - 0
libs/lf-scanner/pypulseq/Sequence/calc_pns.py

@@ -0,0 +1,102 @@
+import math
+from types import SimpleNamespace
+from typing import Tuple, List
+
+import matplotlib.pyplot as plt
+import LF_scanner.pypulseq as pp
+import numpy as np
+
+from LF_scanner.pypulseq import Sequence
+from LF_scanner.pypulseq.utils.safe_pns_prediction import safe_gwf_to_pns, safe_plot
+
+from LF_scanner.pypulseq.utils.siemens.readasc import readasc
+from LF_scanner.pypulseq.utils.siemens.asc_to_hw import asc_to_hw
+
+
+def calc_pns(
+        obj: Sequence,
+        hardware: SimpleNamespace,
+        time_range: List[float] = None,
+        do_plots: bool = True
+        ) -> Tuple[bool, np.array, np.ndarray, np.array]:
+    """
+    Calculate PNS using safe model implementation by Szczepankiewicz and Witzel
+    See http://github.com/filip-szczepankiewicz/safe_pns_prediction
+    
+    Returns pns levels due to respective axes (normalized to 1 and not to 100#)
+    
+    Parameters
+    ----------
+    hardware : SimpleNamespace
+        Hardware specifications. See safe_example_hw() from
+        the safe_pns_prediction package. Alternatively a text file
+        in the .asc format (Siemens) can be passed, e.g. for Prisma
+        it is MP_GPA_K2309_2250V_951A_AS82.asc (we leave it as an
+        exercise to the interested user to find were these files
+        can be acquired from)
+    do_plots : bool, optional
+        Plot the results from the PNS calculations. The default is True.
+
+    Returns
+    -------
+    ok : bool
+        Boolean flag indicating whether peak PNS is within acceptable limits
+    pns_norm : numpy.array [N]
+        PNS norm over all gradient channels, normalized to 1
+    pns_components : numpy.array [Nx3]
+        PNS levels per gradient channel
+    t_pns : np.array [N]
+        Time axis for the pns_norm and pns_components arrays
+    """
+    
+    dt = obj.grad_raster_time
+    # Get gradients as piecewise-polynomials
+    gw_pp = obj.get_gradients(time_range=time_range)
+    ng = len(gw_pp)
+    max_t = max(g.x[-1] for g in gw_pp if g != None) - 1e-10
+    
+    # Determine sampling points
+    if time_range == None:
+        nt = int(np.ceil(max_t/dt))
+        t = (np.arange(nt) + 0.5)*dt
+    else:
+        tmax = min(time_range[1], max_t) - max(time_range[0], 0)
+        nt = int(np.ceil(tmax/dt))
+        t = max(time_range[0], 0) + (np.arange(nt) + 0.5)*dt
+    
+    # Sample gradients
+    gw = np.zeros((t.shape[0], ng))
+    for i in range(ng):
+        if gw_pp[i] != None:
+            gw[:,i] = gw_pp[i](t)
+            
+    
+    if do_plots:
+        plt.figure()
+        for i in range(ng):
+            if gw_pp[i] != None:
+                plt.plot(gw_pp[i].x[1:-1], gw_pp[i].c[1,:-1])
+        plt.title('gradient wave form, in Hz/m')
+    
+    if type(hardware) == str:
+        # this loads the parameters from the provided text file
+        asc, _ = readasc(hardware)
+        hardware = asc_to_hw(asc)
+
+    # use the Szczepankiewicz' and Witzel's implementation
+    [pns_comp,res] = safe_gwf_to_pns(gw/obj.system.gamma, np.nan*np.ones(t.shape[0]), obj.grad_raster_time, hardware) # the RF vector is unused in the code inside but it is zeropaded and exported ... 
+    
+    # use the exported RF vector to detect and undo zero-padding
+    pns_comp = 0.01 * pns_comp[~np.isfinite(res.rf[1:]),:]
+    
+    # calc pns_norm and the final ok/not_ok
+    pns_norm = np.sqrt((pns_comp**2).sum(axis=1))
+    ok = all(pns_norm<1)
+    
+    # ready
+    if do_plots:
+        # plot results
+        plt.figure()
+        safe_plot(pns_comp*100, obj.grad_raster_time)
+
+    return ok, pns_norm, pns_comp, t

+ 247 - 0
libs/lf-scanner/pypulseq/Sequence/ext_test_report.py

@@ -0,0 +1,247 @@
+import numpy as np
+
+from LF_scanner.pypulseq import eps
+from LF_scanner.pypulseq.convert import convert
+
+
+def ext_test_report(self) -> str:
+    """
+    Analyze the sequence and return a text report.
+
+    Returns
+    -------
+    report : str
+
+    """
+    # Find RF pulses and list flip angles
+    flip_angles_deg = []
+    for k in self.rf_library.keys:
+        lib_data = self.rf_library.data[k]
+        if len(self.rf_library.type) >= k:
+            rf = self.rf_from_lib_data(lib_data, self.rf_library.type[k])
+        else:
+            rf = self.rf_from_lib_data(lib_data)
+        flip_angles_deg.append(
+            np.abs(np.sum(rf.signal[:-1] * (rf.t[1:] - rf.t[:-1]))) * 360
+        )
+
+    flip_angles_deg = np.unique(flip_angles_deg)
+
+    # Calculate TE, TR
+    duration, num_blocks, event_count = self.duration()
+
+    k_traj_adc, k_traj, t_excitation, t_refocusing, t_adc = self.calculate_kspacePP()
+
+    k_abs_adc = np.sqrt(np.sum(np.square(k_traj_adc), axis=0))
+    k_abs_echo, index_echo = np.min(k_abs_adc), np.argmin(k_abs_adc)
+    t_echo = t_adc[index_echo]
+    if k_abs_echo > eps:
+        i2check = []
+        # Check if ADC k-space trajectory has elements left and right to index_echo
+        if index_echo > 1:
+            i2check.append(index_echo - 1)
+        if index_echo < len(k_abs_adc):
+            i2check.append(index_echo + 1)
+
+        for a in range(len(i2check)):
+            v_i_to_0 = k_traj_adc[:, index_echo]
+            v_i_to_t = k_traj_adc[:, i2check[a]] - k_traj_adc[:, index_echo]
+            # Project v_i_to_0 to v_i_to_t
+            p_vit = np.matmul(v_i_to_0, v_i_to_t) / np.square(np.linalg.norm(v_i_to_t))
+            if p_vit > 0:
+                # We have found a bracket for the echo and the proportionality coefficient is p_vit
+                t_echo = t_adc[index_echo] * (1 - p_vit) + t_adc[i2check[a]] * p_vit
+
+    if len(t_excitation) != 0:
+        t_ex_tmp = t_excitation[t_excitation < t_echo]
+        TE = t_echo - t_ex_tmp[-1]
+    else:
+        TE = np.nan
+
+    if len(t_excitation) < 2:
+        TR = duration
+    else:
+        t_ex_tmp1 = t_excitation[t_excitation > t_echo]
+        if len(t_ex_tmp1) == 0:
+            TR = t_ex_tmp[-1] - t_ex_tmp[-2]
+        else:
+            TR = t_ex_tmp1[0] - t_ex_tmp[-1]
+
+    # Check sequence dimensionality and spatial resolution
+    k_extent = np.max(np.abs(k_traj_adc), axis=1)
+    k_scale = np.max(k_extent)
+    is_cartesian = False
+    if k_scale != 0:
+        k_bins = 4e6
+        k_threshold = k_scale / k_bins
+
+        # Detect unused dimensions and delete them
+        if np.any(k_extent < k_threshold):
+            k_traj_adc = np.delete(k_traj_adc, np.where(k_extent < k_threshold), axis=0)
+            k_extent = np.delete(k_extent, np.where(k_extent < k_threshold), axis=0)
+
+        # Bin the k-space trajectory to detect repetitions / slices
+        k_len = k_traj_adc.shape[1]
+        k_repeat = np.zeros(k_len)
+        k_storage = np.zeros(k_len)
+        k_storage_next = 0
+        k_map = dict()
+        for i in range(k_len):
+            key_string = str(
+                (k_bins + np.round(k_traj_adc[:, i] / k_threshold)).astype(np.int32)
+            )
+            k_storage_ind = k_map.get(key_string)
+            if k_storage_ind is None:
+                k_storage_ind = k_storage_next
+                k_map[key_string] = k_storage_ind
+                k_storage_next += 1
+            k_storage[k_storage_ind] = k_storage[k_storage_ind] + 1
+            k_repeat[i] = k_storage[k_storage_ind]
+
+        repeats_max = np.max(k_storage[:k_storage_next])
+        repeats_min = np.min(k_storage[:k_storage_next])
+        repeats_median = np.median(k_storage[:k_storage_next])
+        repeats_unique = np.unique(k_storage[:k_storage_next])
+        counts_unique = np.zeros_like(repeats_unique)
+        for i in range(len(repeats_unique)):
+            counts_unique[i] = np.sum(
+                repeats_unique[i] == k_storage[: k_storage_next - 1]
+            )
+
+        k_traj_rep1 = k_traj_adc[:, k_repeat == 1]
+
+        k_counters = np.zeros_like(k_traj_rep1)
+        dims = k_traj_rep1.shape[0]
+        k_map = dict()
+        for j in range(dims):
+            k_storage = np.zeros(k_len)
+            k_storage_next = 1
+
+            for i in range(k_traj_rep1.shape[1]):
+                key = np.round(k_traj_rep1[j, i] / k_threshold).astype(np.int32)
+                k_storage_ind = k_map.get(key)
+                if k_storage_ind is None:
+                    k_storage_ind = k_map.get(key + 1)
+                if k_storage_ind is None:
+                    k_storage_ind = k_map.get(key - 1)
+                if k_storage_ind is None:
+                    k_storage_ind = k_storage_next
+                    k_map[key] = k_storage_ind
+                    k_storage_next += 1
+                    k_storage[k_storage_ind] = k_traj_rep1[j, i]
+                k_counters[j, i] = k_storage_ind
+
+        unique_k_positions = np.max(k_counters, axis=1)
+        is_cartesian = np.prod(unique_k_positions) == k_traj_rep1.shape[1]
+    else:
+        unique_k_positions = 1
+
+    # gw_data = self.gradient_waveforms()
+    waveforms_and_times = self.waveforms_and_times()
+    gw_data = waveforms_and_times[0]
+    gws = np.zeros_like(gw_data)
+    ga = np.zeros(len(gw_data))
+    gs = np.zeros(len(gw_data))
+
+    common_time = np.unique(np.concatenate(gw_data, axis=1)[0])
+    gw_ct = np.zeros((len(gw_data), len(common_time)))
+    gs_ct = np.zeros((len(gw_data), len(common_time) - 1))
+    for gc in range(len(gw_data)):
+        if gw_data[gc].shape[1] > 0:
+            # Slew
+            gws[gc] = (gw_data[gc][1, 1:] - gw_data[gc][1, :-1]) / (
+                gw_data[gc][0, 1:] - gw_data[gc][0, :-1]
+            )
+
+            # Interpolate to common time
+            gw_ct[gc] = np.interp(
+                x=common_time,
+                xp=gw_data[gc][0, :],
+                fp=gw_data[gc][1, :],
+                left=0,
+                right=0,
+            )
+
+            gs_ct[gc] = (gw_ct[gc][1:] - gw_ct[gc][:-1]) / (
+                common_time[1:] - common_time[:-1]
+            )
+
+            # Max grad/slew per channel
+            ga[gc] = np.max(np.abs(gw_data[gc][1:]))
+            gs[gc] = np.max(np.abs(gws[gc]))
+
+    ga_abs = np.max(np.sqrt(np.sum(np.square(gw_ct), axis=0)))
+    gs_abs = np.max(np.sqrt(np.sum(np.square(gs_ct), axis=0)))
+
+    timing_ok, timing_error_report = self.check_timing()
+
+    report = (
+        f"Number of blocks: {num_blocks}\n"
+        f"Number of events:\n"
+        f"RF: {event_count[1]:6.0f}\n"
+        f"Gx: {event_count[2]:6.0f}\n"
+        f"Gy: {event_count[3]:6.0f}\n"
+        f"Gz: {event_count[4]:6.0f}\n"
+        f"ADC: {event_count[5]:6.0f}\n"
+        f"Delay: {event_count[0]:6.0f}\n"
+        f"Sequence duration: {duration:.6f} s\n"
+        f"TE: {TE:.6f} s\n"
+        f"TR: {TR:.6f} s\n"
+    )
+    report += (
+        "Flip angle: "
+        + ("{:.02f} " * len(flip_angles_deg)).format(*flip_angles_deg)
+        + "deg\n"
+    )
+    report += (
+        "Unique k-space positions (aka cols, rows, etc.): "
+        + ("{:.0f} " * len(unique_k_positions)).format(*unique_k_positions)
+        + "\n"
+    )
+
+    if np.any(unique_k_positions > 1):
+        report += f"Dimensions: {len(k_extent)}\n"
+        report += ("Spatial resolution: {:.02f} mm\n" * len(k_extent)).format(
+            *(0.5 / k_extent * 1e3)
+        )
+        report += f"Repetitions/slices/contrasts: {repeats_median}; range: [{repeats_min, repeats_max}]\n"
+
+        if is_cartesian:
+            report += "Cartesian encoding trajectory detected\n"
+        else:
+            report += "Non-cartesian/irregular encoding trajectory detected (eg: EPI, spiral, radial, etc.)\n"
+
+    if timing_ok:
+        report += "Event timing check passed successfully\n"
+    else:
+        report += (
+            f"Event timing check failed. Error listing follows:\n {timing_error_report}"
+        )
+
+    ga_converted = convert(from_value=ga, from_unit="Hz/m", to_unit="mT/m")
+    gs_converted = convert(from_value=gs, from_unit="Hz/m/s", to_unit="T/m/s")
+    report += (
+        "Max gradient: "
+        + ("{:.0f} " * len(ga)).format(*ga)
+        + "Hz/m == "
+        + ("{:.02f} " * len(ga_converted)).format(*ga_converted)
+        + "mT/m\n"
+    )
+    report += (
+        "Max slew rate: "
+        + ("{:.0f} " * len(gs)).format(*gs)
+        + "Hz/m/s == "
+        + ("{:.02f} " * len(ga_converted)).format(*gs_converted)
+        + "T/m/s\n"
+    )
+
+    ga_abs_converted = convert(from_value=ga_abs, from_unit="Hz/m", to_unit="mT/m")
+    gs_abs_converted = convert(from_value=gs_abs, from_unit="Hz/m/s", to_unit="T/m/s")
+    report += (
+        f"Max absolute gradient: {ga_abs:.0f} Hz/m == {ga_abs_converted:.2f} mT/m\n"
+    )
+    report += (
+        f"Max absolute slew rate: {gs_abs:g} Hz/m/s == {gs_abs_converted:.2f} T/m/s"
+    )
+
+    return report

+ 86 - 0
libs/lf-scanner/pypulseq/Sequence/parula.py

@@ -0,0 +1,86 @@
+from matplotlib.colors import LinearSegmentedColormap
+
+
+def main(N: int) -> LinearSegmentedColormap:
+    """
+    Returns a Parula colormap to be used with matplotlib's `cycler`. `cm_data` has values copied from MATLAB for
+    `parula(64)`.
+
+    Parameters
+    ----------
+    N : int
+        Number of RGB quantization levels.
+
+    Returns
+    -------
+    LinearSegmentedColormap
+        Parula color map.
+    """
+    cm_data = [
+        [0.2422, 0.1504, 0.6603],
+        [0.25039048, 0.16499524, 0.70761429],
+        [0.25777143, 0.18178095, 0.7511381],
+        [0.26472857, 0.19775714, 0.79521429],
+        [0.27064762, 0.21467619, 0.83637143],
+        [0.27511429, 0.2342381, 0.87098571],
+        [0.2783, 0.25587143, 0.89907143],
+        [0.28033333, 0.27823333, 0.9221],
+        [0.2813381, 0.30059524, 0.94137619],
+        [0.28101429, 0.32275714, 0.95788571],
+        [0.27946667, 0.34467143, 0.97167619],
+        [0.27597143, 0.36668095, 0.98290476],
+        [0.26991429, 0.3892, 0.9906],
+        [0.26024286, 0.41232857, 0.99515714],
+        [0.24403333, 0.43583333, 0.99883333],
+        [0.22064286, 0.46025714, 0.99728571],
+        [0.19633333, 0.48471905, 0.98915238],
+        [0.18340476, 0.50737143, 0.97979524],
+        [0.17864286, 0.52885714, 0.96815714],
+        [0.1764381, 0.54990476, 0.95201905],
+        [0.16874286, 0.5702619, 0.93587143],
+        [0.154, 0.5902, 0.9218],
+        [0.14602857, 0.60911905, 0.90785714],
+        [0.13802381, 0.62762857, 0.89729048],
+        [0.12481429, 0.64592857, 0.88834286],
+        [0.11125238, 0.6635, 0.87631429],
+        [0.09520952, 0.67982857, 0.85978095],
+        [0.06887143, 0.69477143, 0.83935714],
+        [0.02966667, 0.70816667, 0.81633333],
+        [0.00357143, 0.72026667, 0.7917],
+        [0.00665714, 0.73121429, 0.76601429],
+        [0.04332857, 0.74109524, 0.73940952],
+        [0.09639524, 0.75, 0.7120381],
+        [0.14077143, 0.7584, 0.68415714],
+        [0.1717, 0.7669619, 0.65544286],
+        [0.19376667, 0.77576667, 0.6251],
+        [0.21608571, 0.7843, 0.5923],
+        [0.24695714, 0.79179524, 0.55674286],
+        [0.29061429, 0.79729048, 0.51882857],
+        [0.34064286, 0.8008, 0.47885714],
+        [0.3909, 0.80287143, 0.43544762],
+        [0.44562857, 0.80241905, 0.39091905],
+        [0.5044, 0.7993, 0.348],
+        [0.5615619, 0.79423333, 0.30448095],
+        [0.61739524, 0.78761905, 0.2612381],
+        [0.67198571, 0.77927143, 0.2227],
+        [0.7242, 0.76984286, 0.19102857],
+        [0.77383333, 0.75980476, 0.16460952],
+        [0.82031429, 0.74981429, 0.15352857],
+        [0.86343333, 0.7406, 0.15963333],
+        [0.90354286, 0.73302857, 0.17741429],
+        [0.93925714, 0.72878571, 0.20995714],
+        [0.97275714, 0.72977143, 0.23944286],
+        [0.99564762, 0.74337143, 0.23714762],
+        [0.99698571, 0.76585714, 0.21994286],
+        [0.99520476, 0.78925238, 0.2027619],
+        [0.9892, 0.81356667, 0.18853333],
+        [0.97862857, 0.83862857, 0.17655714],
+        [0.96764762, 0.8639, 0.16429048],
+        [0.96100952, 0.88901905, 0.15367619],
+        [0.95967143, 0.91345714, 0.14225714],
+        [0.96279524, 0.9373381, 0.12650952],
+        [0.96911429, 0.96062857, 0.1063619],
+        [0.9769, 0.9839, 0.0805],
+    ]
+
+    return LinearSegmentedColormap.from_list(name="parula", colors=cm_data, N=N)

+ 660 - 0
libs/lf-scanner/pypulseq/Sequence/read_seq.py

@@ -0,0 +1,660 @@
+import re
+import warnings
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Dict, Tuple, List
+
+import numpy as np
+
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.compress_shape import compress_shape
+from LF_scanner.pypulseq.decompress_shape import decompress_shape
+from LF_scanner.pypulseq.event_lib import EventLibrary
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_labels
+
+
+def read(self, path: str, detect_rf_use: bool = False, remove_duplicates: bool = True) -> None:
+    """
+    Load sequence from file - read the given filename and load sequence data into sequence object.
+
+    See also `pypulseq.Sequence.write_seq.write()`.
+
+    Parameters
+    ----------
+    path : Path
+        Path of sequence file to be read.
+    detect_rf_use : bool, default=False
+        Boolean flag to let the function infer the currently missing flags concerning the intended use of the RF pulses
+        (excitation, refocusing, etc). These are important for the k-space trajectory calculation.
+    remove_duplicates: bool, default=True
+        Remove duplicate events from the sequence after reading
+
+    Raises
+    ------
+    FileNotFoundError
+        If no sequence file is found at `path`.
+    RuntimeError
+        If incompatible sequence files are attempted to be loaded.
+    ValueError
+        If unexpected sections are encountered when loading a sequence file.
+    """
+    try:
+        input_file = open(path, "r")
+    except FileNotFoundError as e:
+        raise FileNotFoundError(e)
+
+    # Event libraries
+    self.adc_library = EventLibrary()
+    self.grad_library = EventLibrary()
+    self.label_inc_library = EventLibrary()
+    self.label_set_library = EventLibrary()
+    self.rf_library = EventLibrary()
+    self.shape_library = EventLibrary()
+    self.trigger_library = EventLibrary()
+
+    # Raster times
+    self.grad_raster_time = self.system.grad_raster_time
+    self.rf_raster_time = self.system.rf_raster_time
+
+    self.block_events = {}
+    self.definitions = {}
+    self.extension_string_idx = []
+    self.extension_numeric_idx = []
+
+    jemris_generated = False
+    version_combined = 0
+
+    # Load data from file
+    while True:
+        section = __skip_comments(input_file)
+        if section == -1:
+            break
+        if section == "[DEFINITIONS]":
+            self.definitions = __read_definitions(input_file)
+
+            # Gradient raster time
+            if "GradientRasterTime" in self.definitions:
+                self.gradient_raster_time = self.definitions["GradientRasterTime"]
+
+            # Radio frequency raster time
+            if "RadiofrequencyRasterTime" in self.definitions:
+                self.rf_raster_time = self.definitions["RadiofrequencyRasterTime"]
+
+            # ADC raster time
+            if "AdcRasterTime" in self.definitions:
+                self.adc_raster_time = self.definitions["AdcRasterTime"]
+
+            # Block duration raster
+            if "BlockDurationRaster" in self.definitions:
+                self.block_duration_raster = self.definitions["BlockDurationRaster"]
+            else:
+                warnings.warn(f"No BlockDurationRaster found in file. Using default of {self.block_duration_raster}.")
+
+        elif section == "[JEMRIS]":
+            jemris_generated = True
+        elif section == "[SIGNATURE]":
+            temp_sign_defs = __read_definitions(input_file)
+            if "Type" in temp_sign_defs:
+                self.signature_type = temp_sign_defs["Type"]
+            if "Hash" in temp_sign_defs:
+                self.signature_value = temp_sign_defs["Hash"]
+                self.signature_file = "Text"
+        elif section == "[VERSION]":
+            version_major, version_minor, version_revision = __read_version(input_file)
+
+            if version_major != self.version_major:
+                raise RuntimeError(
+                    f"Unsupported version_major: {version_major}. Expected: {self.version_major}"
+                )
+
+            version_combined = (
+                    1000000 * version_major + 1000 * version_minor + version_revision
+            )
+
+            if version_combined < 1002000:
+                raise RuntimeError(
+                    f"Unsupported version {version_major}.{version_minor}.{version_revision}, only file "
+                    f"format revision 1.2.0 and above are supported."
+                )
+
+            if version_combined < 1003001:
+                raise RuntimeError(
+                    f"Loading older Pulseq format file (version "
+                    f"{version_major}.{version_minor}.{version_revision}) some code may function not as "
+                    f"expected"
+                )
+        elif section == "[BLOCKS]":
+            if version_major == 0:
+                raise RuntimeError(
+                    "Pulseq file MUST include [VERSION] section prior to [BLOCKS] section"
+                )
+            result = __read_blocks(
+                input_file,
+                block_duration_raster=self.block_duration_raster,
+                version_combined=version_combined,
+            )
+            self.block_events, self.block_durations, delay_ind_temp = result
+        elif section == "[RF]":
+            if jemris_generated:
+                self.rf_library = __read_events(
+                    input_file, (1, 1, 1, 1, 1), event_library=self.rf_library
+                )
+            else:
+                if version_combined >= 1004000:  # 1.4.x format
+                    self.rf_library = __read_events(
+                        input_file,
+                        (1, 1, 1, 1, 1e-6, 1, 1),
+                        event_library=self.rf_library,
+                    )
+                else:  # 1.3.x and below
+                    self.rf_library = __read_events(
+                        input_file, (1, 1, 1, 1e-6, 1, 1), event_library=self.rf_library
+                    )
+        elif section == "[GRADIENTS]":
+            if version_combined >= 1004000:  # 1.4.x format
+                self.grad_library = __read_events(
+                    input_file, (1, 1, 1, 1e-6), "g", self.grad_library
+                )
+            else:  # 1.3.x and below
+                self.grad_library = __read_events(
+                    input_file, (1, 1, 1e-6), "g", self.grad_library
+                )
+        elif section == "[TRAP]":
+            if jemris_generated:
+                self.grad_library = __read_events(
+                    input_file, (1, 1e-6, 1e-6, 1e-6), "t", self.grad_library
+                )
+            else:
+                self.grad_library = __read_events(
+                    input_file, (1, 1e-6, 1e-6, 1e-6, 1e-6), "t", self.grad_library
+                )
+        elif section == "[ADC]":
+            self.adc_library = __read_events(
+                input_file, (1, 1e-9, 1e-6, 1, 1), event_library=self.adc_library, append=self.system.adc_dead_time
+            )
+        elif section == "[DELAYS]":
+            if version_combined >= 1004000:
+                raise RuntimeError(
+                    "Pulseq file revision 1.4.0 and above MUST NOT contain [DELAYS] section"
+                )
+            temp_delay_library = __read_events(input_file, (1e-6,))
+        elif section == "[SHAPES]":
+            self.shape_library = __read_shapes(
+                input_file, version_major == 1 and version_minor < 4
+            )
+        elif section == "[EXTENSIONS]":
+            self.extensions_library = __read_events(input_file)
+        else:
+            if section[:18] == "extension TRIGGERS":
+                extension_id = int(section[18:])
+                self.set_extension_string_ID("TRIGGERS", extension_id)
+                self.trigger_library = __read_events(
+                    input_file, (1, 1, 1e-6, 1e-6), event_library=self.trigger_library
+                )
+            elif section[:18] == "extension LABELSET":
+                extension_id = int(section[18:])
+                self.set_extension_string_ID("LABELSET", extension_id)
+                l1 = lambda s: int(s)
+                l2 = lambda s: get_supported_labels().index(s) + 1
+                self.label_set_library = __read_and_parse_events(input_file, l1, l2)
+            elif section[:18] == "extension LABELINC":
+                extension_id = int(section[18:])
+                self.set_extension_string_ID("LABELINC", extension_id)
+                l1 = lambda s: int(s)
+                l2 = lambda s: get_supported_labels().index(s) + 1
+                self.label_inc_library = __read_and_parse_events(input_file, l1, l2)
+            else:
+                raise ValueError(f"Unknown section code: {section}")
+
+    input_file.close()  # Close file
+
+    if version_combined < 1002000:
+        raise ValueError(
+            f"Unsupported version {version_combined}, only file format revision 1.2.0 (1002000) and above "
+            f"are supported."
+        )
+
+    # Fix blocks, gradients and RF objects imported from older versions
+    if version_combined < 1004000:
+        # Scan through RF objects
+        for i in self.rf_library.data:
+            self.rf_library.update(i, None, (
+                *self.rf_library.data[i][:3],
+                0,
+                *self.rf_library.data[i][3:]
+            ))
+
+        # Scan through the gradient objects and update 't'-s (trapezoids) und 'g'-s (free-shape gradients)
+        for i in self.grad_library.data:
+            if self.grad_library.type[i] == "t":
+                if self.grad_library.data[i][1] == 0:
+                    if (
+                            abs(self.grad_library.data[i][0]) == 0
+                            and self.grad_library.data[i][2] > 0
+                    ):
+                        d = self.grad_library.data[i]
+                        self.grad_library.update(i, None,
+                                                 (d[0], self.grad_raster_time, d[2] - self.grad_raster_time) + d[3:],
+                                                 self.grad_library.type[i])
+
+                if self.grad_library.data[i][3] == 0:
+                    if (
+                            abs(self.grad_library.data[i][0]) == 0
+                            and self.grad_library.data[i][2] > 0
+                    ):
+                        d = self.grad_library.data[i]
+                        self.grad_library.update(i, None,
+                                                 d[:2] + (d[2] - self.grad_raster_time, self.grad_raster_time) + d[4:],
+                                                 self.grad_library.type[i])
+
+            if self.grad_library.type[i] == "g":
+                self.grad_library.update(i, None, (
+                    self.grad_library.data[i][:2],
+                    0,
+                    self.grad_library.data[i][2:],
+                ), self.grad_library.type[i])
+
+        # For versions prior to 1.4.0 block_durations have not been initialized
+        self.block_durations = dict()
+        # Scan through blocks and calculate durations
+        for block_counter in self.block_events:
+            # Insert delay as temporary block_duration
+            self.block_durations[block_counter] = 0
+            if delay_ind_temp[block_counter] > 0:
+                self.block_durations[block_counter] = temp_delay_library.data[
+                    delay_ind_temp[block_counter]
+                ][0]
+
+            block = self.get_block(block_counter)
+            # Calculate actual block duration
+            self.block_durations[block_counter] = calc_duration(block)
+
+    # TODO: Is it possible to avoid expensive get_block calls here?
+    grad_channels = ["gx", "gy", "gz"]
+    grad_prev_last = np.zeros(len(grad_channels))
+    for block_counter in self.block_events:
+        block = self.get_block(block_counter)
+        block_duration = block.block_duration
+        # We also need to keep track of the event IDs because some PyPulseq files written by external software may contain
+        # repeated entries so searching by content will fail
+        event_idx = self.block_events[block_counter]
+        # Update the objects by filling in the fields not contained in the PyPulseq file
+        for j in range(len(grad_channels)):
+            grad = getattr(block, grad_channels[j])
+            if grad is None:
+                grad_prev_last[j] = 0
+                continue
+
+            if grad.type == "grad":
+                if grad.delay > 0:
+                    grad_prev_last[j] = 0
+
+                if hasattr(grad, "first"):
+                    grad_prev_last[j] = grad.last
+                    continue
+
+                amplitude_ID = event_idx[j + 2]
+                if amplitude_ID in event_idx[
+                                   2:(j + 2)]:  # We did this update for the previous channels, don't do it again.
+                    if self.use_block_cache:
+                        # Update block cache in-place using the first/last values that should now be in the grad_library
+                        grad.first = self.grad_library.data[amplitude_ID][4]
+                        grad.last = self.grad_library.data[amplitude_ID][5]
+                    continue
+
+                grad.first = grad_prev_last[j]
+                if grad.time_id != 0:
+                    grad.last = grad.waveform[-1]
+                    grad_duration = grad.delay + grad.tt[-1]
+                else:
+                    # Restore samples on the edges of the gradient raster intervals for that we need the first sample
+                    # TODO: This code does not always restore reasonable values for grad.last
+                    odd_step1 = [grad.first, *2 * grad.waveform]
+                    odd_step2 = odd_step1 * (np.mod(range(len(odd_step1)), 2) * 2 - 1)
+                    waveform_odd_rest = np.cumsum(odd_step2) * (
+                            np.mod(len(odd_step2), 2) * 2 - 1
+                    )
+                    grad.last = waveform_odd_rest[-1]
+                    grad_duration = (
+                            grad.delay + len(grad.waveform) * self.grad_raster_time
+                    )
+
+                # Bookkeeping
+                grad_prev_last[j] = grad.last
+                eps = np.finfo(np.float64).eps
+                if grad_duration + eps < block_duration:
+                    grad_prev_last[j] = 0
+
+                amplitude = self.grad_library.data[amplitude_ID][0]
+                new_data = (
+                    amplitude,
+                    grad.shape_id,
+                    grad.time_id,
+                    grad.delay,
+                    grad.first,
+                    grad.last,
+                )
+                self.grad_library.update_data(amplitude_ID, None, new_data, "g")
+
+            else:
+                grad_prev_last[j] = 0
+
+    if detect_rf_use:
+        # Find the RF pulses, list flip angles, and work around the current (rev 1.2.0) Pulseq file format limitation
+        # that the RF pulse use is not stored in the file
+        for k in self.rf_library.data:
+            lib_data = self.rf_library.data[k]
+            rf = self.rf_from_lib_data(lib_data)
+            flip_deg = np.abs(np.sum(rf.signal[:-1] * (rf.t[1:] - rf.t[:-1]))) * 360
+            offresonance_ppm = 1e6 * rf.freq_offset / self.system.B0 / self.system.gamma
+            if (
+                    flip_deg < 90.01
+            ):  # Add 0.01 degree to account for rounding errors encountered in very short RF pulses
+                self.rf_library.type[k] = "e"
+            else:
+                if (
+                        rf.shape_dur > 6e-3 and -3.5 <= offresonance_ppm <= -3.4
+                ):  # Approx -3.45
+                    self.rf_library.type[k] = "s"  # Saturation (fat-sat)
+                else:
+                    self.rf_library.type[k] = "r"
+            self.rf_library.data[k] = lib_data
+
+            # Clear block cache for all blocks that contain the modified RF event
+            for block_counter, events in self.block_events.items():
+                if events[1] == k:
+                    del self.block_cache[block_counter]
+
+    # When removing duplicates, remove and remap events in the sequence without
+    # creating a copy.
+    if remove_duplicates:
+        self.remove_duplicates(in_place=True)
+
+
+def __read_definitions(input_file) -> Dict[str, str]:
+    """
+    Read the [DEFINITIONS] section of a sequence fil and return a map of key/value entries.
+
+    Parameters
+    ----------
+    input_file : file object
+        Sequence file.
+
+    Returns
+    -------
+    definitions : dict{str, str}
+        Dict object containing key value pairs of definitions.
+    """
+    definitions = dict()
+    line = __skip_comments(input_file)
+    while line != -1 and not (line == "" or line[0] == "#"):
+        tok = line.split(" ")
+        try:  # Try converting every element into a float
+            [float(x) for x in tok[1:]]
+            value = np.array(tok[1:], dtype=float)
+            if len(value) == 1:  # Avoid array structure for single elements
+                value = value[0]
+            definitions[tok[0]] = value
+        except ValueError:  # Try clause did not work!
+            definitions[tok[0]] = line[len(tok[0]) + 1:].strip()
+        line = __strip_line(input_file)
+
+    return definitions
+
+
+def __read_version(input_file) -> Tuple[int, int, int]:
+    """
+     Read the [VERSION] section of a sequence file.
+
+    Parameters
+    ----------
+    input_file : file object
+        Sequence file.
+
+    Returns
+    -------
+    tuple
+        Major, minor and revision number.
+    """
+    line = __strip_line(input_file)
+    major, minor, revision = 0, 0, 0
+    while line != "" and line[0] != "#":
+        tok = line.split(" ")
+        if tok[0] == "major":
+            major = int(tok[1])
+        elif tok[0] == "minor":
+            minor = int(tok[1])
+        elif tok[0] == "revision":
+            if len(tok[1]) != 1:  # Example: x.y.zpostN
+                tok[1] = tok[1][0]
+            revision = int(tok[1])
+        else:
+            raise RuntimeError(
+                f"Incompatible version. Expected: {major}{minor}{revision}"
+            )
+        line = __strip_line(input_file)
+
+    return major, minor, revision
+
+
+def __read_blocks(
+        input_file, block_duration_raster: float, version_combined: int
+) -> Tuple[Dict[int, np.ndarray], List[float], List[int]]:
+    """
+    Read the [BLOCKS] section of a sequence file and return the event table.
+
+    Parameters
+    ----------
+    input_file : file
+        Sequence file
+
+    Returns
+    -------
+    event_table : dict
+        Dict object containing key value pairs of Pulseq block ID and block definition.
+    block_durations : list
+        Block durations.
+    delay_idx : list
+        Delay IDs.
+    """
+    event_table = dict()
+    block_durations = dict()
+    delay_idx = dict()
+    line = __strip_line(input_file)
+
+    while line != "" and line != "#":
+        block_events = np.fromstring(line, dtype=int, sep=" ")
+
+        if version_combined <= 1002001:
+            event_table[block_events[0]] = np.array([0, *block_events[2:], 0])
+        else:
+            event_table[block_events[0]] = np.array([0, *block_events[2:]])
+
+        delay_id = block_events[0]
+        if version_combined >= 1004000:
+            block_durations[delay_id] = block_events[1] * block_duration_raster
+        else:
+            delay_idx[delay_id] = block_events[1]
+
+        line = __strip_line(input_file)
+
+    return event_table, block_durations, delay_idx
+
+
+def __read_events(
+        input_file,
+        scale: tuple = (1,),
+        event_type: str = str(),
+        event_library: EventLibrary = None,
+        append=None
+) -> EventLibrary:
+    """
+    Read an event section of a sequence file and return a library of events.
+
+    Parameters
+    ----------
+    input_file : file object
+        Sequence file.
+    scale : list, default=(1,)
+        Scale elements according to column vector scale.
+    event_type : str, default=str()
+        Attach the type string to elements of the library.
+    event_library : EventLibrary, default=EventLibrary()
+        Append new events to the given library.
+
+    Returns
+    -------
+    event_library : EventLibrary
+        Event library containing Pulseq events.
+    """
+
+    if event_library is None:
+        event_library = EventLibrary()
+    line = __strip_line(input_file)
+
+    while line != "" and line != "#":
+        data = np.fromstring(line, dtype=float, sep=" ")
+        event_id = data[0]
+        data = tuple(data[1:] * scale)
+        if append != None:
+            data = data + (append,)
+        if event_type == "":
+            event_library.insert(key_id=event_id, new_data=data)
+        else:
+            event_library.insert(key_id=event_id, new_data=data, data_type=event_type)
+        line = __strip_line(input_file)
+
+    return event_library
+
+
+def __read_and_parse_events(input_file, *args: callable) -> EventLibrary:
+    """
+    Read an event section of a sequence file and return a library of events. Event data elements are converted using
+    the provided parser(s). Default parser is `int()`.
+
+    Parameters
+    ----------
+    input_file : file
+    args : callable
+        Event parsers.
+
+    Returns
+    -------
+    EventLibrary
+        Library of events parsed from the events section of a sequence file.
+    """
+    event_library = EventLibrary()
+    line = __strip_line(input_file)
+
+    while line != "" and line != "#":
+        datas = re.split(r"(\s+)", line)
+        datas = [d for d in datas if d != " "]
+        data = np.zeros(len(datas) - 1, dtype=np.int32)
+        event_id = int(datas[0])
+        for i in range(1, len(datas)):
+            if i > len(args):
+                data[i - 1] = int(datas[i])
+            else:
+                data[i - 1] = args[i - 1](datas[i])
+        event_library.insert(key_id=event_id, new_data=data)
+        line = __strip_line(input_file)
+
+    return event_library
+
+
+def __read_shapes(input_file, force_convert_uncompressed: bool) -> EventLibrary:
+    """
+    Read the [SHAPES] section of a sequence file and return a library of shapes.
+
+    Parameters
+    ----------
+    input_file : file
+
+    Returns
+    -------
+    shape_library : EventLibrary
+        `EventLibrary` object containing shape definitions.
+    """
+    shape_library = EventLibrary(numpy_data=True)
+
+    line = __skip_comments(input_file)
+
+    while line != -1 and (line != "" or line[0:8] == "shape_id"):
+        tok = line.split(" ")
+        shape_id = int(tok[1])
+        line = __skip_comments(input_file)
+        tok = line.split(" ")
+        num_samples = int(tok[1])
+        data = []
+        line = __skip_comments(input_file)
+        while line != "" and line != "#":
+            data.append(float(line))
+            line = __strip_line(input_file)
+        line = __skip_comments(input_file, stop_before_section=True)
+
+        # Check if conversion is needed: in v1.4.x we use length(data)==num_samples
+        # As a marker for the uncompressed (stored) data. In older versions this condition could occur by chance
+        if force_convert_uncompressed and len(data) == num_samples:
+            shape = SimpleNamespace()
+            shape.data = data
+            shape.num_samples = num_samples
+            shape = compress_shape(decompress_shape(shape, force_decompression=True))
+            data = np.array([shape.num_samples, *shape.data])
+        else:
+            data.insert(0, num_samples)
+            data = np.asarray(data)
+        shape_library.insert(key_id=shape_id, new_data=data)
+    return shape_library
+
+
+def __skip_comments(input_file, stop_before_section: bool = False) -> str:
+    """
+    Read lines of skipping blank lines and comments and return the next non-comment line.
+
+    Parameters
+    ----------
+    input_file : file
+
+    Returns
+    -------
+    line : str
+        First line in `input_file` after skipping one '#' comment block. Note: File pointer is remembered, so
+        successive calls work as expected.
+    """
+
+    temp_pos = input_file.tell()
+    line = __strip_line(input_file)
+    while line != -1 and (line == "" or line[0] == "#"):
+        temp_pos = input_file.tell()
+        line = __strip_line(input_file)
+
+    if line != -1:
+        if stop_before_section and line[0] == "[":
+            input_file.seek(temp_pos, 0)
+            next_line = ""
+        else:
+            next_line = line
+    else:
+        next_line = -1
+
+    return next_line
+
+
+def __strip_line(input_file) -> str:
+    """
+    Removes spaces and newline whitespaces.
+
+    Parameters
+    ----------
+    input_file : file
+
+    Returns
+    -------
+    line : str
+        First line in input_file after spaces and newline whitespaces have been removed. Note: File pointer is
+        remembered, and hence successive calls work as expected. Returns -1 for eof.
+    """
+    line = (
+        input_file.readline()
+    )  # If line is an empty string, end of the file has been reached
+    return line.strip() if line != "" else -1

+ 1872 - 0
libs/lf-scanner/pypulseq/Sequence/sequence.py

@@ -0,0 +1,1872 @@
+import itertools
+import math
+from collections import OrderedDict
+from types import SimpleNamespace
+from typing import Tuple, List
+from typing import Union
+from warnings import warn
+from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
+from matplotlib.figure import Figure
+import tkinter as tk
+
+try:
+    from typing import Self
+except ImportError:
+    from typing import TypeVar
+    Self = TypeVar('Self', bound='Sequence')
+
+import matplotlib as mpl
+import numpy as np
+from matplotlib import pyplot as plt
+from scipy.interpolate import PPoly
+
+from LF_scanner.pypulseq import eps
+from LF_scanner.pypulseq.calc_rf_center import calc_rf_center
+from LF_scanner.pypulseq.check_timing import check_timing as ext_check_timing
+from LF_scanner.pypulseq.decompress_shape import decompress_shape
+from LF_scanner.pypulseq.event_lib import EventLibrary
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_labels
+from LF_scanner.version import major, minor, revision
+from LF_scanner.pypulseq.block_to_events import block_to_events
+from LF_scanner.pypulseq.utils.cumsum import cumsum
+from copy import deepcopy
+
+from LF_scanner.pypulseq.Sequence import block, parula
+from LF_scanner.pypulseq.Sequence.ext_test_report import ext_test_report
+from LF_scanner.pypulseq.Sequence.read_seq import read
+from LF_scanner.pypulseq.Sequence.write_seq import write as write_seq
+from LF_scanner.pypulseq.Sequence.calc_pns import calc_pns
+from LF_scanner.pypulseq.Sequence.calc_grad_spectrum import calculate_gradient_spectrum
+
+
+class Sequence:
+    """
+    Generate sequences and read/write sequence files. This class defines properties and methods to define a complete MR
+    sequence including RF pulses, gradients, ADC events, etc. The class provides an implementation of the open MR
+    sequence format defined by the Pulseq project. See http://pulseq.github.io/.
+
+    See also `demo_read.py`, `demo_write.py`.
+    """
+
+    version_major = int(major)
+    version_minor = int(minor)
+    version_revision = revision
+
+    def __init__(self, system=None, use_block_cache=True):
+        if system == None:
+            system = Opts()
+
+        # =========
+        # EVENT LIBRARIES
+        # =========
+        self.adc_library = EventLibrary()  # Library of ADC events
+        self.delay_library = EventLibrary()  # Library of delay events
+        # Library of extension events. Extension events form single-linked zero-terminated lists
+        self.extensions_library = EventLibrary()
+        self.grad_library = EventLibrary()  # Library of gradient events
+        self.label_inc_library = (
+            EventLibrary()
+        )  # Library of Label(inc) events (reference from the extensions library)
+        self.label_set_library = (
+            EventLibrary()
+        )  # Library of Label(set) events (reference from the extensions library)
+        self.rf_library = EventLibrary()  # Library of RF events
+        self.shape_library = EventLibrary(numpy_data=True)  # Library of compressed shapes
+        self.trigger_library = EventLibrary()  # Library of trigger events
+
+        # =========
+        # OTHER
+        # =========
+        self.system = system
+
+        self.block_events = OrderedDict()  # Event table
+        self.use_block_cache = use_block_cache
+        self.block_cache = dict()  # Block cache
+        self.next_free_block_ID = 1
+
+        self.definitions = dict()  # Optional sequence definitions
+
+        self.rf_raster_time = (
+            self.system.rf_raster_time
+        )  # RF raster time (system dependent)
+        self.grad_raster_time = (
+            self.system.grad_raster_time
+        )  # Gradient raster time (system dependent)
+        self.adc_raster_time = (
+            self.system.adc_raster_time
+        )  # ADC raster time (system dependent)
+        self.block_duration_raster = self.system.block_duration_raster
+        self.set_definition("AdcRasterTime", self.adc_raster_time)
+        self.set_definition("BlockDurationRaster", self.block_duration_raster)
+        self.set_definition("GradientRasterTime", self.grad_raster_time)
+        self.set_definition("RadiofrequencyRasterTime", self.rf_raster_time)
+        self.signature_type = ""
+        self.signature_file = ""
+        self.signature_value = ""
+
+        self.block_durations = dict()  # Cache of block durations
+        self.extension_numeric_idx = []  # numeric IDs of the used extensions
+        self.extension_string_idx = []  # string IDs of the used extensions
+
+    def __str__(self) -> str:
+        s = "Sequence:"
+        s += "\nshape_library: " + str(self.shape_library)
+        s += "\nrf_library: " + str(self.rf_library)
+        s += "\ngrad_library: " + str(self.grad_library)
+        s += "\nadc_library: " + str(self.adc_library)
+        s += "\ndelay_library: " + str(self.delay_library)
+        s += "\nextensions_library: " + str(
+            self.extensions_library
+        )  # inserted for trigger support by mveldmann
+        s += "\nrf_raster_time: " + str(self.rf_raster_time)
+        s += "\ngrad_raster_time: " + str(self.grad_raster_time)
+        s += "\nblock_events: " + str(len(self.block_events))
+        return s
+
+    def adc_times(
+            self, time_range: List[float] = None
+    ) -> Tuple[np.ndarray, np.ndarray]:
+        """
+        Return time points of ADC sampling points.
+
+        Returns
+        -------
+        t_adc: np.ndarray
+            Contains times of all ADC sample points.
+        fp_adc : np.ndarray
+            Contains frequency and phase offsets of each ADC object (not samples).
+        """
+
+        # Collect ADC timing data
+        t_adc = []
+        fp_adc = []
+
+        curr_dur = 0
+        if time_range == None:
+            blocks = self.block_events
+        else:
+            if len(time_range) != 2:
+                raise ValueError('Time range must be list of two elements')
+            if time_range[0] > time_range[1]:
+                raise ValueError('End time of time_range must be after begin time')
+
+            # Calculate end times of each block
+            bd = np.array(list(self.block_durations.values()))
+            t = np.cumsum(bd)
+            # Search block end times for start of time range
+            begin_block = np.searchsorted(t, time_range[0])
+            # Search block begin times for end of time range
+            end_block = np.searchsorted(t - bd, time_range[1], side='right')
+            blocks = list(self.block_durations.keys())[begin_block:end_block]
+            curr_dur = t[begin_block] - bd[begin_block]
+
+        for block_counter in blocks:
+            block = self.get_block(block_counter)
+
+            if block.adc is not None:  # ADC
+                t_adc.append(
+                    (np.arange(block.adc.num_samples) + 0.5) * block.adc.dwell
+                    + block.adc.delay
+                    + curr_dur
+                )
+                fp_adc.append([block.adc.freq_offset, block.adc.phase_offset])
+
+            curr_dur += self.block_durations[block_counter]
+
+        if t_adc == []:
+            # If there are no ADCs, make sure the output is the right shape
+            t_adc = np.zeros(0)
+            fp_adc = np.zeros((0, 2))
+        else:
+            t_adc = np.concatenate(t_adc)
+            fp_adc = np.array(fp_adc)
+
+        return t_adc, fp_adc
+
+    def add_block(self, *args: SimpleNamespace) -> None:
+        """
+        Add a new block/multiple events to the sequence. Adds a sequence block with provided as a block structure
+
+        See also:
+        - `pypulseq.Sequence.sequence.Sequence.set_block()`
+        - `pypulseq.make_adc.make_adc()`
+        - `pypulseq.make_trapezoid.make_trapezoid()`
+        - `pypulseq.make_sinc_pulse.make_sinc_pulse()`
+
+        Parameters
+        ----------
+        args : SimpleNamespace
+            Block structure or events to be added as a block to `Sequence`.
+        """
+        block.set_block(self, self.next_free_block_ID, *args)
+        self.next_free_block_ID += 1
+
+    def calculate_gradient_spectrum(
+            self, max_frequency: float = 2000,
+            window_width: float = 0.05,
+            frequency_oversampling: float = 3,
+            time_range: List[float] = None,
+            plot: bool = True,
+            combine_mode: str = 'max',
+            use_derivative: bool = False,
+            acoustic_resonances: List[dict] = []
+    ) -> Tuple[List[np.ndarray], np.ndarray, np.ndarray, np.ndarray]:
+        """
+        Calculates the gradient spectrum of the sequence. Returns a spectrogram
+        for each gradient channel, as well as a root-sum-squares combined
+        spectrogram.
+
+        Works by splitting the sequence into windows that are 'window_width'
+        long and calculating the fourier transform of each window. Windows
+        overlap 50% with the previous and next window. When 'combine_mode' is
+        not 'none', all windows are combined into one spectrogram.
+
+        Parameters
+        ----------
+        max_frequency : float, optional
+            Maximum frequency to include in spectrograms. The default is 2000.
+        window_width : float, optional
+            Window width (in seconds). The default is 0.05.
+        frequency_oversampling : float, optional
+            Oversampling in the frequency dimension, higher values make
+            smoother spectrograms. The default is 3.
+        time_range : List[float], optional
+            Time range over which to calculate the spectrograms as a list of
+            two timepoints (in seconds) (e.g. [1, 1.5])
+            The default is None.
+        plot : bool, optional
+            Whether to plot the spectograms. The default is True.
+        combine_mode : str, optional
+            How to combine all windows into one spectrogram, options:
+                'max', 'mean', 'sos' (root-sum-of-squares), 'none' (no combination)
+            The default is 'max'.
+        use_derivative : bool, optional
+            Whether the use the derivative of the gradient waveforms instead of the
+            gradient waveforms for the gradient spectrum calculations. The default
+            is False
+        acoustic_resonances : List[dict], optional
+            Acoustic resonances as a list of dictionaries with 'frequency' and
+            'bandwidth' elements. Only used when plot==True. The default is [].
+
+        Returns
+        -------
+        spectrograms : List[np.ndarray]
+            List of spectrograms per gradient channel.
+        spectrogram_sos : np.ndarray
+            Root-sum-of-squares combined spectrogram over all gradient channels.
+        frequencies : np.ndarray
+            Frequency axis of the spectrograms.
+        times : np.ndarray
+            Time axis of the spectrograms (only relevant when combine_mode == 'none').
+
+        """
+        return calculate_gradient_spectrum(self, max_frequency=max_frequency,
+                                           window_width=window_width,
+                                           frequency_oversampling=frequency_oversampling,
+                                           time_range=time_range,
+                                           plot=plot,
+                                           combine_mode=combine_mode,
+                                           use_derivative=use_derivative,
+                                           acoustic_resonances=acoustic_resonances)
+
+    def calculate_kspace(
+            self,
+            trajectory_delay: Union[float, List[float], np.ndarray] = 0,
+            gradient_offset: Union[float, List[float], np.ndarray] = 0
+    ) -> Tuple[np.array, np.array, List[float], List[float], np.array]:
+        """
+        Calculates the k-space trajectory of the entire pulse sequence.
+
+        Parameters
+        ----------
+        trajectory_delay : float or list, default=0
+            Compensation factor in seconds (s) to align ADC and gradients in the reconstruction.
+        gradient_offset : float or list, default=0
+            Simulates background gradients (specified in Hz/m)
+
+        Returns
+        -------
+        k_traj_adc : numpy.array
+            K-space trajectory sampled at `t_adc` timepoints.
+        k_traj : numpy.array
+            K-space trajectory of the entire pulse sequence.
+        t_excitation : List[float]
+            Excitation timepoints.
+        t_refocusing : List[float]
+            Refocusing timepoints.
+        t_adc : numpy.array
+            Sampling timepoints.
+        """
+        if np.any(np.abs(trajectory_delay) > 100e-6):
+            raise Warning(
+                f"Trajectory delay of {trajectory_delay * 1e6} us is suspiciously high"
+            )
+
+        total_duration = sum(self.block_durations.values())
+
+        t_excitation, fp_excitation, t_refocusing, _ = self.rf_times()
+        t_adc, _ = self.adc_times()
+
+        # Convert data to piecewise polynomials
+        gw_pp = self.get_gradients(trajectory_delay, gradient_offset)
+        ng = len(gw_pp)
+
+        # Calculate slice positions. For now we entirely rely on the excitation -- ignoring complicated interleaved
+        # refocused sequences
+        if len(t_excitation) > 0:
+            # Position in x, y, z
+            slice_pos = np.zeros((ng, len(t_excitation)))
+            for j in range(ng):
+                if gw_pp[j] is None:
+                    slice_pos[j] = np.nan
+                else:
+                    # Check for divisions by zero to avoid numpy warning
+                    divisor = np.array(gw_pp[j](t_excitation))
+                    slice_pos[j, divisor != 0.0] = fp_excitation[0, divisor != 0.0] / divisor[divisor != 0.0]
+                    slice_pos[j, divisor == 0.0] = np.nan
+
+            slice_pos[~np.isfinite(slice_pos)] = 0  # Reset undefined to 0
+        else:
+            slice_pos = []
+
+        # =========
+        # Integrate waveforms as PPs to produce gradient moments
+        gm_pp = []
+        tc = []
+        for i in range(ng):
+            if gw_pp[i] is None:
+                gm_pp.append(None)
+                continue
+
+            gm_pp.append(gw_pp[i].antiderivative())
+            tc.append(gm_pp[i].x)
+            # "Sample" ramps for display purposes otherwise piecewise-linear display (plot) fails
+            ii = np.flatnonzero(np.abs(gm_pp[i].c[0, :]) > eps)
+
+            # Do nothing if there are no ramps
+            if ii.shape[0] == 0:
+                continue
+
+            starts = np.int64(np.floor((gm_pp[i].x[ii] + eps) / self.grad_raster_time))
+            ends = np.int64(np.ceil((gm_pp[i].x[ii + 1] - eps) / self.grad_raster_time))
+
+            # Create all ranges starts[0]:ends[0], starts[1]:ends[1], etc.
+            lengths = ends - starts + 1
+            inds = np.ones((lengths).sum())
+            # Calculate output index where each range will start
+            start_inds = np.cumsum(np.concatenate(([0], lengths[:-1])))
+            # Create element-wise differences that will cumsum into
+            # the final indices: [starts[0], 1, 1, starts[1]-starts[0]-lengths[0]+1, 1, etc.]
+            inds[start_inds] = np.concatenate(([starts[0]], np.diff(starts) - lengths[:-1] + 1))
+
+            tc.append(np.cumsum(inds) * self.grad_raster_time)
+        if tc != []:
+            tc = np.concatenate(tc)
+
+        t_acc = 1e-10  # Temporal accuracy
+        t_acc_inv = 1 / t_acc
+        # tc = self.__flatten_jagged_arr(tc)
+        t_ktraj = t_acc * np.unique(
+            np.round(
+                t_acc_inv
+                * np.array(
+                    [
+                        *tc,
+                        0,
+                        *np.asarray(t_excitation) - 2 * self.rf_raster_time,
+                        *np.asarray(t_excitation) - self.rf_raster_time,
+                        *t_excitation,
+                        *np.asarray(t_refocusing) - self.rf_raster_time,
+                        *t_refocusing,
+                        *t_adc,
+                        total_duration,
+                    ]
+                )
+            )
+        )
+
+        i_excitation = np.searchsorted(t_ktraj, t_acc * np.round(t_acc_inv * np.asarray(t_excitation)))
+        i_refocusing = np.searchsorted(t_ktraj, t_acc * np.round(t_acc_inv * np.asarray(t_refocusing)))
+        i_adc = np.searchsorted(t_ktraj, t_acc * np.round(t_acc_inv * np.asarray(t_adc)))
+
+        i_periods = np.unique([0, *i_excitation, *i_refocusing, len(t_ktraj) - 1])
+        if len(i_excitation) > 0:
+            ii_next_excitation = 0
+        else:
+            ii_next_excitation = -1
+        if len(i_refocusing) > 0:
+            ii_next_refocusing = 0
+        else:
+            ii_next_refocusing = -1
+
+        k_traj = np.zeros((ng, len(t_ktraj)))
+        for i in range(ng):
+            if gw_pp[i] is None:
+                continue
+
+            it = np.where(np.logical_and(
+                t_ktraj >= t_acc * round(t_acc_inv * gm_pp[i].x[0]),
+                t_ktraj <= t_acc * round(t_acc_inv * gm_pp[i].x[-1]),
+            ))[0]
+            k_traj[i, it] = gm_pp[i](t_ktraj[it])
+            if t_ktraj[it[-1]] < t_ktraj[-1]:
+                k_traj[i, it[-1] + 1:] = k_traj[i, it[-1]]
+
+        # Convert gradient moments to kspace
+        dk = -k_traj[:, 0]
+        for i in range(len(i_periods) - 1):
+            i_period = i_periods[i]
+            i_period_end = i_periods[i + 1]
+            if ii_next_excitation >= 0 and i_excitation[ii_next_excitation] == i_period:
+                if abs(t_ktraj[i_period] - t_excitation[ii_next_excitation]) > t_acc:
+                    raise Warning(
+                        f"abs(t_ktraj[i_period]-t_excitation[ii_next_excitation]) < {t_acc} failed for ii_next_excitation={ii_next_excitation} error={t_ktraj(i_period) - t_excitation(ii_next_excitation)}"
+                    )
+                dk = -k_traj[:, i_period]
+                if i_period > 0:
+                    # Use nans to mark the excitation points since they interrupt the plots
+                    k_traj[:, i_period - 1] = np.nan
+                # -1 on len(i_excitation) for 0-based indexing
+                ii_next_excitation = min(len(i_excitation) - 1, ii_next_excitation + 1)
+            elif (
+                    ii_next_refocusing >= 0 and i_refocusing[ii_next_refocusing] == i_period
+            ):
+                # dk = -k_traj[:, i_period]
+                dk = -2 * k_traj[:, i_period] - dk
+                # -1 on len(i_excitation) for 0-based indexing
+                ii_next_refocusing = min(len(i_refocusing) - 1, ii_next_refocusing + 1)
+
+            k_traj[:, i_period:i_period_end] = (
+                    k_traj[:, i_period:i_period_end] + dk[:, None]
+            )
+
+        k_traj[:, i_period_end] = k_traj[:, i_period_end] + dk
+        k_traj_adc = k_traj[:, i_adc]
+
+        return k_traj_adc, k_traj, t_excitation, t_refocusing, t_adc
+
+    def calculate_kspacePP(
+            self,
+            trajectory_delay: Union[float, List[float], np.ndarray] = 0,
+            gradient_offset: Union[float, List[float], np.ndarray] = 0
+    ) -> Tuple[np.array, np.array, np.array, np.array, np.array]:
+        warn('Sequence.calculate_kspacePP has been deprecated, use calculate_kspace instead', DeprecationWarning,
+             stacklevel=2)
+        return self.calculate_kspace(trajectory_delay, gradient_offset)
+
+    def calculate_pns(
+            self,
+            hardware: SimpleNamespace,
+            time_range: List[float] = None,
+            do_plots: bool = True
+    ) -> Tuple[bool, np.array, np.ndarray, np.array]:
+        """
+        Calculate PNS using safe model implementation by Szczepankiewicz and Witzel
+        See http://github.com/filip-szczepankiewicz/safe_pns_prediction
+
+        Returns pns levels due to respective axes (normalized to 1 and not to 100#)
+
+        Parameters
+        ----------
+        hardware : SimpleNamespace
+            Hardware specifications. See safe_example_hw() from
+            the safe_pns_prediction package. Alternatively a text file
+            in the .asc format (Siemens) can be passed, e.g. for Prisma
+            it is MP_GPA_K2309_2250V_951A_AS82.asc (we leave it as an
+            exercise to the interested user to find were these files
+            can be acquired from)
+        do_plots : bool, optional
+            Plot the results from the PNS calculations. The default is True.
+
+        Returns
+        -------
+        ok : bool
+            Boolean flag indicating whether peak PNS is within acceptable limits
+        pns_norm : numpy.array [N]
+            PNS norm over all gradient channels, normalized to 1
+        pns_components : numpy.array [Nx3]
+            PNS levels per gradient channel
+        t_pns : np.array [N]
+            Time axis for the pns_norm and pns_components arrays
+        """
+        return calc_pns(self, hardware, time_range=time_range, do_plots=do_plots)
+
+    def check_timing(self) -> Tuple[bool, List[str]]:
+        """
+        Checks timing of all blocks and objects in the sequence optionally returns the detailed error log. This
+        function also modifies the sequence object by adding the field "TotalDuration" to sequence definitions.
+
+        Returns
+        -------
+        is_ok : bool
+            Boolean flag indicating timing errors.
+        error_report : str
+            Error report in case of timing errors.
+        """
+        error_report = []
+        is_ok = True
+        total_duration = 0
+
+        for block_counter in self.block_events:
+            block = self.get_block(block_counter)
+            events = block_to_events(block)
+            res, rep, duration = ext_check_timing(self.system, *events)
+            is_ok = is_ok and res
+
+            # Check the stored total block duration
+            if abs(duration - self.block_durations[block_counter]) > eps:
+                rep += "Inconsistency between the stored block duration and the duration of the block content"
+                is_ok = False
+                duration = self.block_durations[block_counter]
+
+            # Check RF dead times
+            if block.rf is not None:
+                if block.rf.delay - block.rf.dead_time < -eps:
+                    rep += (
+                        f"Delay of {block.rf.delay * 1e6} us is smaller than the RF dead time "
+                        f"{block.rf.dead_time * 1e6} us"
+                    )
+                    is_ok = False
+
+                if (
+                        block.rf.delay + block.rf.t[-1] + block.rf.ringdown_time - duration
+                        > eps
+                ):
+                    rep += (
+                        f"Time between the end of the RF pulse at {block.rf.delay + block.rf.t[-1]} and the end "
+                        f"of the block at {duration * 1e6} us is shorter than rf_ringdown_time"
+                    )
+                    is_ok = False
+
+            # Check ADC dead times
+            if block.adc is not None:
+                if block.adc.delay - self.system.adc_dead_time < -eps:
+                    rep += "adc.delay < system.adc_dead_time"
+                    is_ok = False
+
+                if (
+                        block.adc.delay
+                        + block.adc.num_samples * block.adc.dwell
+                        + self.system.adc_dead_time
+                        - duration
+                        > eps
+                ):
+                    rep += "adc: system.adc_dead_time (post-ADC) violation"
+                    is_ok = False
+
+            # Update report
+            if len(rep) != 0:
+                error_report.append(f"Event: {block_counter} - {rep}\n")
+            total_duration += duration
+
+        # Check if all the gradients in the last block are ramped down properly
+        if len(events) != 0 and all([isinstance(e, SimpleNamespace) for e in events]):
+            for e in range(len(events)):
+                if not isinstance(events[e], list) and events[e].type == "grad":
+                    if events[e].last != 0:
+                        error_report.append(
+                            f"Event: {list(self.block_events)[-1]} - Gradients do not ramp to 0 at the end of the sequence"
+                        )
+
+        self.set_definition("TotalDuration", total_duration)
+
+        return is_ok, error_report
+
+    def duration(self) -> Tuple[int, int, np.ndarray]:
+        """
+        Returns the total duration of this sequence, and the total count of blocks and events.
+
+        Returns
+        -------
+        duration : int
+            Duration of this sequence in seconds (s).
+        num_blocks : int
+            Number of blocks in this sequence.
+        event_count : np.ndarray
+            Number of events in this sequence.
+        """
+        num_blocks = len(self.block_events)
+        event_count = np.zeros(len(next(iter(self.block_events.values()))))
+        duration = 0
+        for block_counter in self.block_events:
+            event_count += self.block_events[block_counter] > 0
+            duration += self.block_durations[block_counter]
+
+        return duration, num_blocks, event_count
+
+    def evaluate_labels(
+            self,
+            init: dict = None,
+            evolution: str = 'none'
+    ) -> dict:
+        """
+        Evaluate label values of the entire sequence.
+
+        When no evolution is given, returns the label values at the end of the
+        sequence. Returns a dictionary with keys named after the labels used
+        in the sequence. Only the keys corresponding to the labels actually
+        used are created.
+        E.g. labels['LIN'] == 4
+
+        When evolution is given, labels are tracked through the sequence. See
+        below for options for different types of evolutions. The resulting
+        dictionary will contain arrays of the label values.
+        E.g. labels['LIN'] == np.array([0,1,2,3,4])
+
+        Initial values for the labels can be given with the 'init' parameter.
+        Useful if evaluating labels block-by-block.
+
+        Parameters
+        ----------
+        init : dict, optional
+            Dictionary containing initial label values. The default is None.
+        evolution : str, optional
+            Flag to specify tracking of label evolutions.
+            Must be one of: 'none', 'adc', 'label', 'blocks' (default = 'none')
+            'blocks': Return label values for all blocks.
+            'adc':    Return label values only for blocks containing ADC events.
+            'label':  Return label values only for blocks where labels are
+                      manipulated.
+
+        Returns
+        -------
+        labels : dict
+            Dictionary containing label values.
+            If evolution == 'none', the dictionary values only contains the
+            final label value.
+            Otherwise, the dictionary values are arrays of label evolutions.
+            Only the labels that are used in the sequence are created in the
+            dictionary.
+
+        """
+        labels = init or dict()
+        label_evolution = []
+
+        # TODO: MATLAB implementation includes block_range parameter. But in
+        #       general we cannot assume linear block ordering. Could include
+        #       time_range like in other sequence functions. Or a blocks
+        #       parameter to specify which blocks to loop over?
+        for block_counter in self.block_events:
+            block = self.get_block(block_counter)
+
+            if block.label is not None:
+                # Current block has labels
+                for lab in block.label.values():
+                    if lab.type == 'labelinc':
+                        # Increment label
+                        if lab.label not in labels:
+                            labels[lab.label] = 0
+
+                        labels[lab.label] += lab.value
+                    else:
+                        # Set label
+                        labels[lab.label] = lab.value
+
+                if evolution == 'label':
+                    label_evolution.append(dict(labels))
+
+            if evolution == 'blocks' or (evolution == 'adc' and block.adc is not None):
+                label_evolution.append(dict(labels))
+
+        # Convert evolutions into label dictionary
+        if len(label_evolution) > 0:
+            for lab in labels:
+                labels[lab] = np.array([e[lab] if lab in e else 0 for e in label_evolution])
+
+        return labels
+
+    def flip_grad_axis(self, axis: str) -> None:
+        """
+        Invert all gradients along the corresponding axis/channel. The function acts on all gradient objects already
+        added to the sequence object.
+
+        Parameters
+        ----------
+        axis : str
+            Gradients to invert or scale. Must be one of 'x', 'y' or 'z'.
+        """
+        self.mod_grad_axis(axis, modifier=-1)
+
+    def get_block(self, block_index: int) -> SimpleNamespace:
+        """
+        Return a block of the sequence  specified by the index. The block is created from the sequence data with all
+        events and shapes decompressed.
+
+        See also:
+        - `pypulseq.Sequence.sequence.Sequence.set_block()`.
+        - `pypulseq.Sequence.sequence.Sequence.add_block()`.
+
+        Parameters
+        ----------
+        block_index : int
+            Index of block to be retrieved from `Sequence`.
+
+        Returns
+        -------
+        SimpleNamespace
+            Event identified by `block_index`.
+        """
+        return block.get_block(self, block_index)
+
+    def get_definition(self, key: str) -> str:
+        """
+        Return value of the definition specified by the key. These definitions can be added manually or read from the
+        header of a sequence file defined in the sequence header. An empty array is returned if the key is not defined.
+
+        See also `pypulseq.Sequence.sequence.Sequence.set_definition()`.
+
+        Parameters
+        ----------
+        key : str
+            Key of definition to retrieve.
+
+        Returns
+        -------
+        str
+            Definition identified by `key` if found, else returns ''.
+        """
+        if key in self.definitions:
+            return self.definitions[key]
+        else:
+            return ""
+
+    def get_extension_type_ID(self, extension_string: str) -> int:
+        """
+        Get numeric extension ID for `extension_string`. Will automatically create a new ID if unknown.
+
+        Parameters
+        ----------
+        extension_string : str
+            Given string extension ID.
+
+        Returns
+        -------
+        extension_id : int
+            Numeric ID for given string extension ID.
+
+        """
+        if extension_string not in self.extension_string_idx:
+            if len(self.extension_numeric_idx) == 0:
+                extension_id = 1
+            else:
+                extension_id = 1 + max(self.extension_numeric_idx)
+
+            self.extension_numeric_idx.append(extension_id)
+            self.extension_string_idx.append(extension_string)
+            assert len(self.extension_numeric_idx) == len(self.extension_string_idx)
+        else:
+            num = self.extension_string_idx.index(extension_string)
+            extension_id = self.extension_numeric_idx[num]
+
+        return extension_id
+
+    def get_extension_type_string(self, extension_id: int) -> str:
+        """
+        Get string extension ID for `extension_id`.
+
+        Parameters
+        ----------
+        extension_id : int
+            Given numeric extension ID.
+
+        Returns
+        -------
+        extension_str : str
+            String ID for the given numeric extension ID.
+
+        Raises
+        ------
+        ValueError
+            If given numeric extension ID is unknown.
+        """
+        if extension_id in self.extension_numeric_idx:
+            num = self.extension_numeric_idx.index(extension_id)
+        else:
+            raise ValueError(
+                f"Extension for the given ID - {extension_id} - is unknown."
+            )
+
+        extension_str = self.extension_string_idx[num]
+        return extension_str
+
+    def get_gradients(self,
+                      trajectory_delay: Union[float, List[float], np.ndarray] = 0,
+                      gradient_offset: Union[float, List[float], np.ndarray] = 0,
+                      time_range: List[float] = None) -> List[PPoly]:
+        """
+        Get all gradient waveforms of the sequence in a piecewise-polynomial
+        format (scipy PPoly). Gradient values can be accessed easily at one or
+        more timepoints using `gw_pp[channel](t)` (where t is a float, list of
+        floats, or numpy array). Note that PPoly objects return nan for
+        timepoints outside the waveform.
+
+        Parameters
+        ----------
+        trajectory_delay : float or list, default=0
+            Compensation factor in seconds (s) to align ADC and gradients in the reconstruction.
+        gradient_offset : float or list, default=0
+            Simulates background gradients (specified in Hz/m)
+
+        Returns
+        -------
+        gw_pp : List[PPoly]
+            List of gradient waveforms for each of the gradient channels,
+            expressed as scipy PPoly objects.
+        """
+        if np.any(np.abs(trajectory_delay) > 100e-6):
+            raise Warning(
+                f"Trajectory delay of {trajectory_delay * 1e6} us is suspiciously high"
+            )
+
+        total_duration = sum(self.block_durations.values())
+
+        gw_data = self.waveforms(time_range=time_range)
+        ng = len(gw_data)
+
+        # Gradient delay handling
+        if isinstance(trajectory_delay, (int, float)):
+            gradient_delays = [trajectory_delay] * ng
+        else:
+            assert (len(trajectory_delay) == ng)  # Need to have same number of gradient channels
+            gradient_delays = [trajectory_delay] * ng
+
+        # Gradient offset handling
+        if isinstance(gradient_offset, (int, float)):
+            gradient_offset = [gradient_offset] * ng
+        else:
+            assert (len(gradient_offset) == ng)  # Need to have same number of gradient channels
+
+        # Convert data to piecewise polynomials
+        gw_pp = []
+        for j in range(ng):
+            wave_cnt = gw_data[j].shape[1]
+            if wave_cnt == 0:
+                if np.abs(gradient_offset[j]) <= eps:
+                    gw_pp.append(None)
+                    continue
+                else:
+                    gw = np.array(([0, total_duration], [0, 0]))
+            else:
+                gw = gw_data[j]
+
+            # Now gw contains the waveform from the current axis
+            if np.abs(gradient_delays[j]) > eps:
+                gw[0] = gw[0] - gradient_delays[j]  # Anisotropic gradient delay support
+            if not np.all(np.isfinite(gw)):
+                raise Warning("Not all elements of the generated waveform are finite.")
+
+            teps = 1e-12
+            _temp1 = np.array(([gw[0, 0] - 2 * teps, gw[0, 0] - teps], [0, 0]))
+            _temp2 = np.array(([gw[0, -1] + teps, gw[0, -1] + 2 * teps], [0, 0]))
+            gw = np.hstack((_temp1, gw, _temp2))
+
+            if np.abs(gradient_offset[j]) > eps:
+                gw[1, :] += gradient_offset[j]
+
+            gw[1][gw[1] == -0.0] = 0.0
+
+            gw_pp.append(PPoly(np.stack((np.diff(gw[1]) / np.diff(gw[0]),
+                                         gw[1][:-1])), gw[0], extrapolate=True))
+        return gw_pp
+
+    def mod_grad_axis(self, axis: str, modifier: int) -> None:
+        """
+        Invert or scale all gradients along the corresponding axis/channel. The function acts on all gradient objects
+        already added to the sequence object.
+
+        Parameters
+        ----------
+        axis : str
+            Gradients to invert or scale. Must be one of 'x', 'y' or 'z'.
+        modifier : int
+            Scaling value.
+
+        Raises
+        ------
+        ValueError
+            If invalid `axis` is passed. Must be one of 'x', 'y','z'.
+        RuntimeError
+            If same gradient event is used on multiple axes.
+        """
+        if axis not in ["x", "y", "z"]:
+            raise ValueError(
+                f"Invalid axis. Must be one of 'x', 'y','z'. Passed: {axis}"
+            )
+
+        channel_num = ["x", "y", "z"].index(axis)
+        other_channels = [0, 1, 2]
+        other_channels.remove(channel_num)
+
+        # Go through all event table entries and list gradient objects in the library
+        all_grad_events = np.array(list(self.block_events.values()))
+        all_grad_events = all_grad_events[:, 2:5]
+
+        selected_events = np.unique(all_grad_events[:, channel_num])
+        selected_events = selected_events[selected_events != 0]
+        other_events = np.unique(all_grad_events[:, other_channels])
+        if len(np.intersect1d(selected_events, other_events)) > 0:
+            raise RuntimeError(
+                "mod_grad_axis does not yet support the same gradient event used on multiple axes."
+            )
+
+        for i in range(len(selected_events)):
+            self.grad_library.data[selected_events[i]][0] *= modifier
+            if (
+                    self.grad_library.type[selected_events[i]] == "g"
+                    and self.grad_library.lengths[selected_events[i]] == 5
+            ):
+                # Need to update first and last fields
+                self.grad_library.data[selected_events[i]][3] *= modifier
+                self.grad_library.data[selected_events[i]][4] *= modifier
+
+    def plot(
+            self, tk_obj,
+            label: str = str(),
+            show_blocks: bool = False,
+            save: bool = False,
+            time_range=(0, np.inf),
+            time_disp: str = "s",
+            grad_disp: str = "kHz/m",
+            plot_now: bool = True,
+            tk_plot: bool = True
+    ) -> None:
+        """
+        Plot `Sequence`.
+
+        Parameters
+        ----------
+        label : str, defualt=str()
+            Plot label values for ADC events: in this example for LIN and REP labels; other valid labes are accepted as
+            a comma-separated list.
+        save : bool, default=False
+            Boolean flag indicating if plots should be saved. The two figures will be saved as JPG with numerical
+            suffixes to the filename 'seq_plot'.
+        show_blocks : bool, default=False
+            Boolean flag to indicate if grid and tick labels at the block boundaries are to be plotted.
+        time_range : iterable, default=(0, np.inf)
+            Time range (x-axis limits) for plotting the sequence. Default is 0 to infinity (entire sequence).
+        time_disp : str, default='s'
+            Time display type, must be one of `s`, `ms` or `us`.
+        grad_disp : str, default='s'
+            Gradient display unit, must be one of `kHz/m` or `mT/m`.
+        plot_now : bool, default=True
+            If true, function immediately shows the plots, blocking the rest of the code until plots are exited.
+            If false, plots are shown when plt.show() is called. Useful if plots are to be modified.
+        plot_type : str, default='Gradient'
+            Gradients display type, must be one of either 'Gradient' or 'Kspace'.
+        """
+        mpl.rcParams["lines.linewidth"] = 0.75  # Set default Matplotlib linewidth
+
+        valid_time_units = ["s", "ms", "us"]
+        valid_grad_units = ["kHz/m", "mT/m"]
+        valid_labels = get_supported_labels()
+        if (
+                not all([isinstance(x, (int, float)) for x in time_range])
+                or len(time_range) != 2
+        ):
+            raise ValueError("Invalid time range")
+        if time_disp not in valid_time_units:
+            raise ValueError("Unsupported time unit")
+
+        if grad_disp not in valid_grad_units:
+            raise ValueError(
+                "Unsupported gradient unit. Supported gradient units are: "
+                + str(valid_grad_units)
+            )
+
+        fig1, fig2 = plt.figure(1), plt.figure(2)
+
+        sp11 = fig1.add_subplot(311)
+        sp12 = fig1.add_subplot(312, sharex=sp11)
+        sp13 = fig1.add_subplot(313, sharex=sp11)
+        fig2_subplots = [
+            fig2.add_subplot(311, sharex=sp11),
+            fig2.add_subplot(312, sharex=sp11),
+            fig2.add_subplot(313, sharex=sp11),
+        ]
+
+        t_factor_list = [1, 1e3, 1e6]
+        t_factor = t_factor_list[valid_time_units.index(time_disp)]
+
+        g_factor_list = [1e-3, 1e3 / self.system.gamma]
+        g_factor = g_factor_list[valid_grad_units.index(grad_disp)]
+
+        t0 = 0
+        label_defined = False
+        label_idx_to_plot = []
+        label_legend_to_plot = []
+        label_store = dict()
+        for i in range(len(valid_labels)):
+            label_store[valid_labels[i]] = 0
+            if valid_labels[i] in label.upper():
+                label_idx_to_plot.append(i)
+                label_legend_to_plot.append(valid_labels[i])
+
+        if len(label_idx_to_plot) != 0:
+            p = parula.main(len(label_idx_to_plot) + 1)
+            label_colors_to_plot = p(np.arange(len(label_idx_to_plot)))
+            cycler = mpl.cycler(color=label_colors_to_plot)
+            sp11.set_prop_cycle(cycler)
+
+        # Block timings
+        block_edges = np.cumsum([0] + [x[1] for x in sorted(self.block_durations.items())])
+        block_edges_in_range = block_edges[
+            (block_edges >= time_range[0]) * (block_edges <= time_range[1])
+            ]
+        if show_blocks:
+            for sp in [sp11, sp12, sp13, *fig2_subplots]:
+                sp.set_xticks(t_factor * block_edges_in_range)
+                sp.set_xticklabels(sp.get_xticklabels(), rotation=90)
+
+        for block_counter in self.block_events:
+            block = self.get_block(block_counter)
+            is_valid = (time_range[0] <= t0 + self.block_durations[block_counter]
+                        and t0 <= time_range[1])
+            if is_valid:
+                if getattr(block, "label", None) is not None:
+                    for i in range(len(block.label)):
+                        if block.label[i].type == "labelinc":
+                            label_store[block.label[i].label] += block.label[i].value
+                        else:
+                            label_store[block.label[i].label] = block.label[i].value
+                    label_defined = True
+
+                if getattr(block, "adc", None) is not None:  # ADC
+                    adc = block.adc
+                    # From Pulseq: According to the information from Klaus Scheffler and indirectly from Siemens this
+                    # is the present convention - the samples are shifted by 0.5 dwell
+                    t = adc.delay + (np.arange(int(adc.num_samples)) + 0.5) * adc.dwell
+                    sp11.plot(t_factor * (t0 + t), np.zeros(len(t)), "rx")
+                    sp13.plot(
+                        t_factor * (t0 + t),
+                        np.angle(
+                            np.exp(1j * adc.phase_offset)
+                            * np.exp(1j * 2 * np.pi * t * adc.freq_offset)
+                        ),
+                        "b.",
+                        markersize=0.25,
+                    )
+
+                    if label_defined and len(label_idx_to_plot) != 0:
+                        arr_label_store = list(label_store.values())
+                        lbl_vals = np.take(arr_label_store, label_idx_to_plot)
+                        t = t0 + adc.delay + (adc.num_samples - 1) / 2 * adc.dwell
+                        _t = [t_factor * t] * len(lbl_vals)
+                        # Plot each label individually to retrieve each corresponding Line2D object
+                        p = itertools.chain.from_iterable(
+                            [
+                                sp11.plot(__t, _lbl_vals, ".")
+                                for __t, _lbl_vals in zip(_t, lbl_vals)
+                            ]
+                        )
+                        if len(label_legend_to_plot) != 0:
+                            sp11.legend(p, label_legend_to_plot, loc="upper left")
+                            label_legend_to_plot = []
+
+                if getattr(block, "rf", None) is not None:  # RF
+                    rf = block.rf
+                    tc, ic = calc_rf_center(rf)
+                    time = rf.t
+                    signal = rf.signal
+                    if abs(signal[0]) != 0:
+                        signal = np.concatenate(([0], signal))
+                        time = np.concatenate(([time[0]], time))
+                        ic += 1
+
+                    if abs(signal[-1]) != 0:
+                        signal = np.concatenate((signal, [0]))
+                        time = np.concatenate((time, [time[-1]]))
+
+                    sp12.plot(t_factor * (t0 + time + rf.delay), np.abs(signal))
+                    sp13.plot(
+                        t_factor * (t0 + time + rf.delay),
+                        np.angle(
+                            signal
+                            * np.exp(1j * rf.phase_offset)
+                            * np.exp(1j * 2 * math.pi * time * rf.freq_offset)
+                        ),
+                        t_factor * (t0 + tc + rf.delay),
+                        np.angle(
+                            signal[ic]
+                            * np.exp(1j * rf.phase_offset)
+                            * np.exp(1j * 2 * math.pi * time[ic] * rf.freq_offset)
+                        ),
+                        "xb",
+                    )
+
+                grad_channels = ["gx", "gy", "gz"]
+                for x in range(len(grad_channels)):  # Gradients
+                    if getattr(block, grad_channels[x], None) is not None:
+                        grad = getattr(block, grad_channels[x])
+                        if grad.type == "grad":
+                            # We extend the shape by adding the first and the last points in an effort of making the
+                            # display a bit less confusing...
+                            time = grad.delay + np.array([0, *grad.tt, grad.shape_dur])
+                            waveform = g_factor * np.array(
+                                (grad.first, *grad.waveform, grad.last)
+                            )
+                        else:
+                            time = np.array(cumsum(
+                                0,
+                                grad.delay,
+                                grad.rise_time,
+                                grad.flat_time,
+                                grad.fall_time,
+                            ))
+                            waveform = (
+                                    g_factor * grad.amplitude * np.array([0, 0, 1, 1, 0])
+                            )
+                        fig2_subplots[x].plot(t_factor * (t0 + time), waveform)
+            t0 += self.block_durations[block_counter]
+
+        grad_plot_labels = ["x", "y", "z"]
+        sp11.set_ylabel("ADC")
+        sp12.set_ylabel("RF mag (Hz)")
+        sp13.set_ylabel("RF/ADC phase (rad)")
+        sp13.set_xlabel(f"t ({time_disp})")
+        for x in range(3):
+            _label = grad_plot_labels[x]
+            fig2_subplots[x].set_ylabel(f"G{_label} ({grad_disp})")
+        fig2_subplots[-1].set_xlabel(f"t ({time_disp})")
+
+        # Setting display limits
+        disp_range = t_factor * np.array([time_range[0], min(t0, time_range[1])])
+        [x.set_xlim(disp_range) for x in [sp11, sp12, sp13, *fig2_subplots]]
+
+        # Grid on
+        for sp in [sp11, sp12, sp13, *fig2_subplots]:
+            sp.grid()
+
+        fig1.tight_layout()
+        fig2.tight_layout()
+        if save:
+            fig1.savefig("seq_plot1.jpg")
+            fig2.savefig("seq_plot2.jpg")
+
+        if tk_plot:
+            root = tk.Tk()
+            root.title("График с панелью инструментов")
+
+            canvas = FigureCanvasTkAgg(fig1, master=root)
+            canvas.draw()
+
+            # Добавляем панель инструментов (зумирование, сохранение и т. д.)
+            toolbar = NavigationToolbar2Tk(canvas, root)
+            toolbar.update()
+
+            canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
+
+        if plot_now:
+            plt.show()
+
+    def read(self, file_path: str, detect_rf_use: bool = False, remove_duplicates: bool = True) -> None:
+        """
+        Read `.seq` file from `file_path`.
+
+        Parameters
+        ----------
+        detect_rf_use
+        file_path : str
+            Path to `.seq` file to be read.
+        remove_duplicates : bool, default=True
+            Remove duplicate events from the sequence after reading.
+        """
+        if self.use_block_cache:
+            self.block_cache.clear()
+
+        read(self, path=file_path, detect_rf_use=detect_rf_use, remove_duplicates=remove_duplicates)
+
+        # Initialize next free block ID
+        self.next_free_block_ID = (max(self.block_events) + 1) if self.block_events else 1
+
+    def register_adc_event(self, event: EventLibrary) -> int:
+        return block.register_adc_event(self, event)
+
+    def register_grad_event(
+            self, event: SimpleNamespace
+    ) -> Union[int, Tuple[int, int]]:
+        return block.register_grad_event(self, event)
+
+    def register_label_event(self, event: SimpleNamespace) -> int:
+        return block.register_label_event(self, event)
+
+    def register_rf_event(self, event: SimpleNamespace) -> Tuple[int, List[int]]:
+        return block.register_rf_event(self, event)
+
+    def remove_duplicates(self, in_place: bool = False) -> Self:
+        """
+        Removes duplicate events from the shape and event libraries contained
+        in this sequence.
+
+        Parameters
+        ----------
+        in_place : bool, optional
+            If true, removes the duplicates from the current sequence.
+            Otherwise, a copy is created. The default is False.
+
+        Returns
+        -------
+        seq_copy : Sequence
+            If `in_place`, returns self. Otherwise returns a copy of the
+            sequence.
+        """
+        if in_place:
+            seq_copy = self
+        else:
+            # Avoid copying block_cache for performance
+            tmp = self.block_cache
+            self.block_cache = {}
+            seq_copy = deepcopy(self)
+            self.block_cache = tmp
+
+        # Find duplicate in shape library
+        seq_copy.shape_library, mapping = seq_copy.shape_library.remove_duplicates(9)
+
+        # Remap shape IDs of arbitrary gradient events
+        for grad_id in seq_copy.grad_library.data:
+            if seq_copy.grad_library.type[grad_id] == 'g':
+                data = seq_copy.grad_library.data[grad_id]
+                new_data = (data[0],) + (mapping[data[1]], mapping[data[2]]) + data[3:]
+                if data != new_data:
+                    seq_copy.grad_library.update(grad_id, None, new_data)
+
+        # Remap shape IDs of RF events
+        for rf_id in seq_copy.rf_library.data:
+            data = seq_copy.rf_library.data[rf_id]
+            new_data = (data[0],) + (mapping[data[1]], mapping[data[2]], mapping[data[3]]) + data[4:]
+            if data != new_data:
+                seq_copy.rf_library.update(rf_id, None, new_data)
+
+        # Filter duplicates in gradient library
+        seq_copy.grad_library, mapping = seq_copy.grad_library.remove_duplicates((6, -6, -6, -6, -6, -6))
+
+        # Remap gradient event IDs
+        for block_id in seq_copy.block_events:
+            seq_copy.block_events[block_id][2] = mapping[seq_copy.block_events[block_id][2]]
+            seq_copy.block_events[block_id][3] = mapping[seq_copy.block_events[block_id][3]]
+            seq_copy.block_events[block_id][4] = mapping[seq_copy.block_events[block_id][4]]
+
+        # Filter duplicates in RF library
+        seq_copy.rf_library, mapping = seq_copy.rf_library.remove_duplicates((6, 0, 0, 0, 6, 6, 6))
+
+        # Remap RF event IDs
+        for block_id in seq_copy.block_events:
+            seq_copy.block_events[block_id][1] = mapping[seq_copy.block_events[block_id][1]]
+
+        # Filter duplicates in ADC library
+        seq_copy.adc_library, mapping = seq_copy.adc_library.remove_duplicates((0, -9, -6, 6, 6, 6))
+
+        # Remap ADC event IDs
+        for block_id in seq_copy.block_events:
+            seq_copy.block_events[block_id][5] = mapping[seq_copy.block_events[block_id][5]]
+
+        return seq_copy
+
+    def rf_from_lib_data(self, lib_data: list, use: str = str()) -> SimpleNamespace:
+        """
+        Construct RF object from `lib_data`.
+
+        Parameters
+        ----------
+        lib_data : list
+            RF envelope.
+        use : str, default=str()
+            RF event use.
+
+        Returns
+        -------
+        rf : SimpleNamespace
+            RF object constructed from `lib_data`.
+        """
+        rf = SimpleNamespace()
+        rf.type = "rf"
+
+        amplitude, mag_shape, phase_shape = lib_data[0], lib_data[1], lib_data[2]
+        shape_data = self.shape_library.data[mag_shape]
+        compressed = SimpleNamespace()
+        compressed.num_samples = shape_data[0]
+        compressed.data = shape_data[1:]
+        mag = decompress_shape(compressed)
+        shape_data = self.shape_library.data[phase_shape]
+        compressed.num_samples = shape_data[0]
+        compressed.data = shape_data[1:]
+        phase = decompress_shape(compressed)
+        rf.signal = amplitude * mag * np.exp(1j * 2 * np.pi * phase)
+        time_shape = lib_data[3]
+        if time_shape > 0:
+            shape_data = self.shape_library.data[time_shape]
+            compressed.num_samples = shape_data[0]
+            compressed.data = shape_data[1:]
+            rf.t = decompress_shape(compressed) * self.rf_raster_time
+            rf.shape_dur = (
+                    math.ceil((rf.t[-1] - eps) / self.rf_raster_time) * self.rf_raster_time
+            )
+        else:  # Generate default time raster on the fly
+            rf.t = (np.arange(1, len(rf.signal) + 1) - 0.5) * self.rf_raster_time
+            rf.shape_dur = len(rf.signal) * self.rf_raster_time
+
+        rf.delay = lib_data[4]
+        rf.freq_offset = lib_data[5]
+        rf.phase_offset = lib_data[6]
+
+        rf.dead_time = self.system.rf_dead_time
+        rf.ringdown_time = self.system.rf_ringdown_time
+
+        if use != "":
+            use_cases = {
+                "e": "excitation",
+                "r": "refocusing",
+                "i": "inversion",
+                "s": "saturation",
+                "p": "preparation",
+            }
+            rf.use = use_cases[use] if use in use_cases else "undefined"
+
+        return rf
+
+    def rf_times(
+            self, time_range: List[float] = None
+    ) -> Tuple[List[float], np.ndarray, List[float], np.ndarray, np.ndarray]:
+        """
+        Return time points of excitations and refocusings.
+
+        Returns
+        -------
+        t_excitation : List[float]
+            Contains time moments of the excitation RF pulses
+        fp_excitation : np.ndarray
+            Contains frequency and phase offsets of the excitation RF pulses
+        t_refocusing : List[float]
+            Contains time moments of the refocusing RF pulses
+        fp_refocusing : np.ndarray
+            Contains frequency and phase offsets of the excitation RF pulses
+        """
+
+        # Collect RF timing data
+        t_excitation = []
+        fp_excitation = []
+        t_refocusing = []
+        fp_refocusing = []
+
+        curr_dur = 0
+        if time_range == None:
+            blocks = self.block_events
+        else:
+            if len(time_range) != 2:
+                raise ValueError('Time range must be list of two elements')
+            if time_range[0] > time_range[1]:
+                raise ValueError('End time of time_range must be after begin time')
+
+            # Calculate end times of each block
+            bd = np.array(list(self.block_durations.values()))
+            t = np.cumsum(bd)
+            # Search block end times for start of time range
+            begin_block = np.searchsorted(t, time_range[0])
+            # Search block begin times for end of time range
+            end_block = np.searchsorted(t - bd, time_range[1], side='right')
+            blocks = list(self.block_durations.keys())[begin_block:end_block]
+            curr_dur = t[begin_block] - bd[begin_block]
+
+        for block_counter in blocks:
+            block = self.get_block(block_counter)
+
+            if block.rf is not None:  # RF
+                rf = block.rf
+                t = rf.delay + calc_rf_center(rf)[0]
+                if not hasattr(rf, "use") or block.rf.use in [
+                    "excitation",
+                    "undefined",
+                ]:
+                    t_excitation.append(curr_dur + t)
+                    fp_excitation.append([block.rf.freq_offset, block.rf.phase_offset])
+                elif block.rf.use == "refocusing":
+                    t_refocusing.append(curr_dur + t)
+                    fp_refocusing.append([block.rf.freq_offset, block.rf.phase_offset])
+
+            curr_dur += self.block_durations[block_counter]
+
+        if len(t_excitation) != 0:
+            fp_excitation = np.array(fp_excitation).T
+        else:
+            fp_excitation = np.empty((2, 0))
+
+        if len(t_refocusing) != 0:
+            fp_refocusing = np.array(fp_refocusing).T
+        else:
+            fp_refocusing = np.empty((2, 0))
+
+        return t_excitation, fp_excitation, t_refocusing, fp_refocusing
+
+    def set_block(self, block_index: int, *args: SimpleNamespace) -> None:
+        """
+        Replace block at index with new block provided as block structure, add sequence block, or create a new block
+        from events and store at position specified by index. The block or events are provided in uncompressed form and
+        will be stored in the compressed, non-redundant internal libraries.
+
+        See also:
+        - `pypulseq.Sequence.sequence.Sequence.get_block()`
+        - `pypulseq.Sequence.sequence.Sequence.add_block()`
+
+        Parameters
+        ----------
+        block_index : int
+            Index at which block is replaced.
+        args : SimpleNamespace
+            Block or events to be replaced/added or created at `block_index`.
+        """
+        block.set_block(self, block_index, *args)
+
+        if block_index >= self.next_free_block_ID:
+            self.next_free_block_ID = block_index + 1
+
+    def set_definition(
+            self, key: str, value: Union[float, int, list, np.ndarray, str, tuple]
+    ) -> None:
+        """
+        Modify a custom definition of the sequence. Set the user definition 'key' to value 'value'. If the definition
+        does not exist it will be created.
+
+        See also `pypulseq.Sequence.sequence.Sequence.get_definition()`.
+
+        Parameters
+        ----------
+        key : str
+            Definition key.
+        value : int, list, np.ndarray, str or tuple
+            Definition value.
+        """
+        if key == "FOV":
+            if np.max(value) > 1:
+                text = "Definition FOV uses values exceeding 1 m. "
+                text += "New Pulseq interpreters expect values in units of meters."
+                warn(text)
+
+        self.definitions[key] = value
+
+    def set_extension_string_ID(self, extension_str: str, extension_id: int) -> None:
+        """
+        Set numeric ID for the given string extension ID.
+
+        Parameters
+        ----------
+        extension_str : str
+            Given string extension ID.
+        extension_id : int
+            Given numeric extension ID.
+
+        Raises
+        ------
+        ValueError
+            If given numeric or string extension ID is not unique.
+        """
+        if (
+                extension_str in self.extension_string_idx
+                or extension_id in self.extension_numeric_idx
+        ):
+            raise ValueError("Numeric or string ID is not unique")
+
+        self.extension_numeric_idx.append(extension_id)
+        self.extension_string_idx.append(extension_str)
+        assert len(self.extension_numeric_idx) == len(self.extension_string_idx)
+
+    def test_report(self) -> str:
+        """
+        Analyze the sequence and return a text report.
+        """
+        return ext_test_report(self)
+
+    def waveforms(
+            self, append_RF: bool = False, time_range: List[float] = None
+    ) -> Tuple[np.ndarray]:
+        """
+        Decompress the entire gradient waveform. Returns gradient waveforms as a tuple of `np.ndarray` of
+        `gradient_axes` (typically 3) dimensions. Each `np.ndarray` contains timepoints and the corresponding
+        gradient amplitude values.
+
+        Parameters
+        ----------
+        append_RF : bool, default=False
+            Boolean flag to indicate if RF wave shapes are to be appended after the gradients.
+
+        Returns
+        -------
+        wave_data : np.ndarray
+        """
+        grad_channels = ["gx", "gy", "gz"]
+
+        # Collect shape pieces
+        if append_RF:
+            shape_channels = len(grad_channels) + 1  # Last 'channel' is RF
+        else:
+            shape_channels = len(grad_channels)
+
+        shape_pieces = [[] for _ in range(shape_channels)]
+        out_len = np.zeros(shape_channels)  # Last 'channel' is RF
+
+        curr_dur = 0
+        if time_range == None:
+            blocks = self.block_events
+        else:
+            if len(time_range) != 2:
+                raise ValueError('Time range must be list of two elements')
+            if time_range[0] > time_range[1]:
+                raise ValueError('End time of time_range must be after begin time')
+
+            # Calculate end times of each block
+            bd = np.array(list(self.block_durations.values()))
+            t = np.cumsum(bd)
+            # Search block end times for start of time range
+            begin_block = np.searchsorted(t, time_range[0])
+            # Search block begin times for end of time range
+            end_block = np.searchsorted(t - bd, time_range[1], side='right')
+            blocks = list(self.block_durations.keys())[begin_block:end_block]
+            curr_dur = t[begin_block] - bd[begin_block]
+
+        for block_counter in blocks:
+            block = self.get_block(block_counter)
+
+            for j in range(len(grad_channels)):
+                grad = getattr(block, grad_channels[j])
+                if grad is not None:  # Gradients
+                    if grad.type == "grad":
+                        # Check if we have an extended trapezoid or an arbitrary gradient on a regular raster
+                        tt_rast = grad.tt / self.grad_raster_time + 0.5
+                        if np.all(
+                                np.abs(tt_rast - np.arange(1, len(tt_rast) + 1)) < eps
+                        ):  # Arbitrary gradient
+                            """
+                            Arbitrary gradient: restore & recompress shape - if we had a trapezoid converted to shape we
+                            have to find the "corners" and we can eliminate internal samples on the straight segments
+                            but first we have to restore samples on the edges of the gradient raster intervals for that
+                            we need the first sample.
+                            """
+
+                            # TODO: Implement restoreAdditionalShapeSamples
+                            #       https://github.com/pulseq/pulseq/blob/master/matlab/%2Bmr/restoreAdditionalShapeSamples.m
+
+                            out_len[j] += len(grad.tt) + 2
+                            shape_pieces[j].append(np.array(
+                                [
+                                    curr_dur + grad.delay + np.concatenate(
+                                        ([0], grad.tt, [grad.tt[-1] + self.grad_raster_time / 2])),
+                                    np.concatenate(([grad.first], grad.waveform, [grad.last]))
+                                ]
+                            ))
+                        else:  # Extended trapezoid
+                            out_len[j] += len(grad.tt)
+                            shape_pieces[j].append(np.array(
+                                [
+                                    curr_dur + grad.delay + grad.tt,
+                                    grad.waveform,
+                                ]
+                            ))
+                    else:
+                        if abs(grad.flat_time) > eps:
+                            out_len[j] += 4
+                            _temp = np.vstack(
+                                (
+                                    cumsum(
+                                        curr_dur + grad.delay,
+                                        grad.rise_time,
+                                        grad.flat_time,
+                                        grad.fall_time,
+                                    ),
+                                    grad.amplitude * np.array([0, 1, 1, 0]),
+                                )
+                            )
+                            shape_pieces[j].append(_temp)
+                        else:
+                            if abs(grad.rise_time) > eps and abs(grad.fall_time) > eps:
+                                out_len[j] += 3
+                                _temp = np.vstack(
+                                    (
+                                        cumsum(curr_dur + grad.delay, grad.rise_time, grad.fall_time),
+                                        grad.amplitude * np.array([0, 1, 0]),
+                                    )
+                                )
+                                shape_pieces[j].append(_temp)
+                            else:
+                                if abs(grad.amplitude) > eps:
+                                    print(
+                                        'Warning: "empty" gradient with non-zero magnitude detected in block {}'.format(
+                                            block_counter))
+
+            if block.rf is not None:  # RF
+                rf = block.rf
+                if append_RF:
+                    rf_piece = np.array(
+                        [
+                            curr_dur + rf.delay + rf.t,
+                            rf.signal
+                            * np.exp(
+                                1j
+                                * (rf.phase_offset + 2 * np.pi * rf.freq_offset * rf.t)
+                            ),
+                        ]
+                    )
+                    out_len[-1] += len(rf.t)
+
+                    if abs(rf.signal[0]) > 0:
+                        pre = np.array([[rf_piece[0, 0] - 0.1 * self.system.rf_raster_time], [0]])
+                        rf_piece = np.hstack((pre, rf_piece))
+                        out_len[-1] += pre.shape[1]
+
+                    if abs(rf.signal[-1]) > 0:
+                        post = np.array([[rf_piece[0, -1] + 0.1 * self.system.rf_raster_time], [0]])
+                        rf_piece = np.hstack((rf_piece, post))
+                        out_len[-1] += post.shape[1]
+
+                    shape_pieces[-1].append(rf_piece)
+
+            curr_dur += self.block_durations[block_counter]
+
+        # Collect wave data
+        wave_data = []
+
+        for j in range(shape_channels):
+            if shape_pieces[j] == []:
+                wave_data.append(np.zeros((2, 0)))
+                continue
+
+            # If the first element of the next shape has the same time as
+            # the last element of the previous shape, drop the first
+            # element of the next shape.
+            shape_pieces[j] = ([shape_pieces[j][0]] +
+                               [cur if prev[0, -1] + eps < cur[0, 0] else cur[:, 1:]
+                                for prev, cur in zip(shape_pieces[j][:-1], shape_pieces[j][1:])])
+
+            wave_data.append(np.concatenate(shape_pieces[j], axis=1))
+
+            rftdiff = np.diff(wave_data[j][0])
+            if np.any(rftdiff < eps):
+                raise Warning(
+                    "Time vector elements are not monotonically increasing."
+                )
+
+        return wave_data
+
+    def waveforms_and_times(
+            self, append_RF: bool = False, time_range: List[float] = None
+    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
+        """
+        Decompress the entire gradient waveform. Returns gradient waveforms as a tuple of `np.ndarray` of
+        `gradient_axes` (typically 3) dimensions. Each `np.ndarray` contains timepoints and the corresponding
+        gradient amplitude values. Additional return values are time points of excitations, refocusings and ADC
+        sampling points.
+
+        Parameters
+        ----------
+        append_RF : bool, default=False
+            Boolean flag to indicate if RF wave shapes are to be appended after the gradients.
+
+        Returns
+        -------
+        wave_data : np.ndarray
+        tfp_excitation : np.ndarray
+            Contains time moments, frequency and phase offsets of the excitation RF pulses (similar for `
+            tfp_refocusing`).
+        tfp_refocusing : np.ndarray
+        t_adc: np.ndarray
+            Contains times of all ADC sample points.
+        fp_adc : np.ndarray
+            Contains frequency and phase offsets of each ADC object (not samples).
+        """
+
+        wave_data = self.waveforms(append_RF=append_RF, time_range=time_range)
+        t_excitation, fp_excitation, t_refocusing, fp_refocusing = self.rf_times(time_range=time_range)
+        t_adc, fp_adc = self.adc_times(time_range=time_range)
+
+        # Join times, frequency and phases of RF pulses for compatibility with previous implementation
+        tfp_excitation = np.concatenate((np.array(t_excitation)[None], fp_excitation), axis=0)
+        tfp_refocusing = np.concatenate((np.array(t_refocusing)[None], fp_refocusing), axis=0)
+
+        return wave_data, tfp_excitation, tfp_refocusing, t_adc, fp_adc
+
+    def waveforms_export(self, time_range=(0, np.inf)) -> dict:
+        """
+        Plot `Sequence`.
+
+        Parameters
+        ----------
+        time_range : iterable, default=(0, np.inf)
+            Time range (x-axis limits) for all waveforms. Default is 0 to infinity (entire sequence).
+
+        Returns
+        -------
+        all_waveforms: dict
+            Dictionary containing the following sequence waveforms and time array(s):
+            - `t_adc` - ADC timing array [seconds]
+            - `t_rf` - RF timing array [seconds]
+            - `t_rf_centers`: `rf_t_centers`,
+            - `t_gx`: x gradient timing array,
+            - `t_gy`: y gradient timing array,
+            - `t_gz`: z gradient timing array,
+            - `adc` - ADC complex signal (amplitude=1, phase=adc phase) [a.u.]
+            - `rf` - RF complex signal
+            - `rf_centers`: RF centers array,
+            - `gx` - x gradient
+            - `gy` - y gradient
+            - `gz` - z gradient
+            - `grad_unit`: [kHz/m],
+            - `rf_unit`: [Hz],
+            - `time_unit`: [seconds],
+        """
+        # Check time range validity
+        if (
+                not all([isinstance(x, (int, float)) for x in time_range])
+                or len(time_range) != 2
+        ):
+            raise ValueError("Invalid time range")
+
+        t0 = 0
+        adc_t_all = np.array([])
+        adc_signal_all = np.array([], dtype=complex)
+        rf_t_all = np.array([])
+        rf_signal_all = np.array([], dtype=complex)
+        rf_t_centers = np.array([])
+        rf_signal_centers = np.array([], dtype=complex)
+        gx_t_all = np.array([])
+        gy_t_all = np.array([])
+        gz_t_all = np.array([])
+        gx_all = np.array([])
+        gy_all = np.array([])
+        gz_all = np.array([])
+
+        for block_counter in self.block_events:  # For each block
+            block = self.get_block(block_counter)  # Retrieve it
+            is_valid = (
+                    time_range[0] <= t0 <= time_range[1]
+            )  # Check if "current time" is within requested range.
+            if is_valid:
+                # Case 1: ADC
+                if block.adc != None:
+                    adc = block.adc  # Get adc info
+                    # From Pulseq: According to the information from Klaus Scheffler and indirectly from Siemens this
+                    # is the present convention - the samples are shifted by 0.5 dwell
+                    t = adc.delay + (np.arange(int(adc.num_samples)) + 0.5) * adc.dwell
+                    adc_t = t0 + t
+                    adc_signal = np.exp(1j * adc.phase_offset) * np.exp(
+                        1j * 2 * np.pi * t * adc.freq_offset
+                    )
+                    adc_t_all = np.concatenate((adc_t_all, adc_t))
+                    adc_signal_all = np.concatenate((adc_signal_all, adc_signal))
+
+                if block.rf != None:
+                    rf = block.rf
+                    tc, ic = calc_rf_center(rf)
+                    t = rf.t + rf.delay
+                    tc = tc + rf.delay
+
+                    # Debug - visualize
+                    # sp12.plot(t_factor * (t0 + t), np.abs(rf.signal))
+                    # sp13.plot(t_factor * (t0 + t), np.angle(rf.signal * np.exp(1j * rf.phase_offset)
+                    #                                         * np.exp(1j * 2 * math.pi * rf.t * rf.freq_offset)),
+                    #           t_factor * (t0 + tc), np.angle(rf.signal[ic] * np.exp(1j * rf.phase_offset)
+                    #                                          * np.exp(1j * 2 * math.pi * rf.t[ic] * rf.freq_offset)),
+                    #           'xb')
+
+                    rf_t = t0 + t
+                    rf = (
+                            rf.signal
+                            * np.exp(1j * rf.phase_offset)
+                            * np.exp(1j * 2 * math.pi * rf.t * rf.freq_offset)
+                    )
+                    rf_t_all = np.concatenate((rf_t_all, rf_t))
+                    rf_signal_all = np.concatenate((rf_signal_all, rf))
+                    rf_t_centers = np.concatenate((rf_t_centers, [rf_t[ic]]))
+                    rf_signal_centers = np.concatenate((rf_signal_centers, [rf[ic]]))
+
+                grad_channels = ["gx", "gy", "gz"]
+                for x in range(
+                        len(grad_channels)
+                ):  # Check each gradient channel: x, y, and z
+                    if getattr(block, grad_channels[x]) != None:
+                        # If this channel is on in current block
+                        grad = getattr(block, grad_channels[x])
+                        if grad.type == "grad":  # Arbitrary gradient option
+                            # In place unpacking of grad.t with the starred expression
+                            g_t = (
+                                    t0
+                                    + grad.delay
+                                    + [
+                                        0,
+                                        *(grad.tt + (grad.tt[1] - grad.tt[0]) / 2),
+                                        grad.tt[-1] + grad.tt[1] - grad.tt[0],
+                                    ]
+                            )
+                            g = 1e-3 * np.array((grad.first, *grad.waveform, grad.last))
+                        else:  # Trapezoid gradient option
+                            g_t = cumsum(
+                                t0,
+                                grad.delay,
+                                grad.rise_time,
+                                grad.flat_time,
+                                grad.fall_time,
+                            )
+                            g = 1e-3 * grad.amplitude * np.array([0, 0, 1, 1, 0])
+
+                        if grad.channel == "x":
+                            gx_t_all = np.concatenate((gx_t_all, g_t))
+                            gx_all = np.concatenate((gx_all, g))
+                        elif grad.channel == "y":
+                            gy_t_all = np.concatenate((gy_t_all, g_t))
+                            gy_all = np.concatenate((gy_all, g))
+                        elif grad.channel == "z":
+                            gz_t_all = np.concatenate((gz_t_all, g_t))
+                            gz_all = np.concatenate((gz_all, g))
+
+            t0 += self.block_durations[
+                block_counter
+            ]  # "Current time" gets updated to end of block just examined
+
+        all_waveforms = {
+            "t_adc": adc_t_all,
+            "t_rf": rf_t_all,
+            "t_rf_centers": rf_t_centers,
+            "t_gx": gx_t_all,
+            "t_gy": gy_t_all,
+            "t_gz": gz_t_all,
+            "adc": adc_signal_all,
+            "rf": rf_signal_all,
+            "rf_centers": rf_signal_centers,
+            "gx": gx_all,
+            "gy": gy_all,
+            "gz": gz_all,
+            "grad_unit": "[kHz/m]",
+            "rf_unit": "[Hz]",
+            "time_unit": "[seconds]",
+        }
+
+        return all_waveforms
+
+    def write(self, name: str, create_signature: bool = True, remove_duplicates: bool = True) -> Union[str, None]:
+        """
+        Write the sequence data to the given filename using the open file format for MR sequences.
+
+        See also `pypulseq.Sequence.read_seq.read()`.
+
+        Parameters
+        ----------
+        name : str
+            Filename of `.seq` file to be written to disk.
+        create_signature : bool, default=True
+            Boolean flag to indicate if the file has to be signed.
+        remove_duplicates : bool, default=True
+            Remove duplicate events from the sequence before writing
+
+        Returns
+        -------
+        signature or None : If create_signature is True, it returns the written .seq file's signature as a string,
+        otherwise it returns None. Note that, if remove_duplicates is True, signature belongs to the
+        deduplicated sequences signature, and not the Sequence that is stored in the Sequence object.
+        """
+        signature = write_seq(self, name, create_signature)
+
+        if signature is not None:
+            self.signature_type = "md5"
+            self.signature_file = "text"
+            self.signature_value = signature
+            return signature
+        else:
+            return None

+ 269 - 0
libs/lf-scanner/pypulseq/Sequence/write_seq.py

@@ -0,0 +1,269 @@
+import hashlib
+
+import numpy as np
+
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_labels
+
+
+def write(self, file_name: str, create_signature) -> None:
+    """
+    Write the sequence data to the given filename using the open file format for MR sequences.
+
+    See also `pypulseq.Sequence.read_seq.read()`.
+
+    Parameters
+    ----------
+    file_name : str
+        File name of `.seq` file to be written to disk.
+    create_signature : bool
+
+    Raises
+    ------
+    RuntimeError
+        If an unsupported definition is encountered.
+    """
+    # `>.0f` for decimals.
+    # `>g` to truncate insignificant zeros.
+    file_name += ".seq" if file_name[-4:] != ".seq" not in file_name else ""
+    with open(file_name, "w") as output_file:
+        output_file.write("# Pulseq sequence file\n")
+        output_file.write("# Created by PyPulseq\n\n")
+
+        output_file.write("[VERSION]\n")
+        output_file.write(f"major {self.version_major}\n")
+        output_file.write(f"minor {self.version_minor}\n")
+        output_file.write(f"revision {self.version_revision}\n")
+        output_file.write("\n")
+
+        if len(self.definitions) != 0:
+            output_file.write("[DEFINITIONS]\n")
+            keys = sorted(list(self.definitions.keys()))
+            values = [self.definitions[k] for k in keys]
+            for block_counter in range(len(keys)):
+                output_file.write(f"{keys[block_counter]} ")
+                if isinstance(values[block_counter], str):
+                    output_file.write(values[block_counter] + " ")
+                elif isinstance(values[block_counter], (int, float)):
+                    output_file.write(f"{values[block_counter]:0.9g} ")
+                elif isinstance(
+                    values[block_counter], (list, tuple, np.ndarray)
+                ):  # For example, [FOVx, FOVy, FOVz]
+                    for i in range(len(values[block_counter])):
+                        if isinstance(values[block_counter][i], (int, float)):
+                            output_file.write(f"{values[block_counter][i]:0.9g} ")
+                        else:
+                            output_file.write(f"{values[block_counter][i]} ")
+                else:
+                    raise RuntimeError("Unsupported definition")
+                output_file.write("\n")
+            output_file.write("\n")
+
+        output_file.write("# Format of blocks:\n")
+        output_file.write("# NUM DUR RF  GX  GY  GZ  ADC  EXT\n")
+        output_file.write("[BLOCKS]\n")
+        id_format_width = "{:" + str(len(str(len(self.block_events)))) + "d}"
+        id_format_str = id_format_width + " {:3d} {:3d} {:3d} {:3d} {:3d} {:2d} {:2d}\n"
+        for block_counter in range(len(self.block_events)):
+            block_duration = (
+                self.block_durations[block_counter] / self.block_duration_raster
+            )
+            block_duration_rounded = int(np.round(block_duration))
+
+            assert np.abs(block_duration_rounded - block_duration) < 1e-6
+
+            s = id_format_str.format(
+                *(
+                    block_counter + 1,
+                    block_duration_rounded,
+                    *self.block_events[block_counter + 1][1:],
+                )
+            )
+            output_file.write(s)
+        output_file.write("\n")
+
+        if len(self.rf_library.keys) != 0:
+            output_file.write("# Format of RF events:\n")
+            output_file.write(
+                "# id amplitude mag_id phase_id time_shape_id delay freq phase\n"
+            )
+            output_file.write(
+                "# ..        Hz   ....     ....          ....    us   Hz   rad\n"
+            )
+            output_file.write("[RF]\n")
+            rf_lib_keys = self.rf_library.keys
+            id_format_str = "{:.0f} {:12g} {:.0f} {:.0f} {:.0f} {:g} {:g} {:g}\n"  # Refer lines 20-21
+            for k in rf_lib_keys.keys():
+                lib_data1 = self.rf_library.data[k][0:4]
+                lib_data2 = self.rf_library.data[k][5:7]
+                delay = (
+                    np.round(self.rf_library.data[k][4] / self.rf_raster_time)
+                    * self.rf_raster_time
+                    * 1e6
+                )
+                s = id_format_str.format(k, *lib_data1, delay, *lib_data2)
+                output_file.write(s)
+            output_file.write("\n")
+
+        grad_lib_values = np.array(list(self.grad_library.type.values()))
+        arb_grad_mask = grad_lib_values == "g"
+        trap_grad_mask = grad_lib_values == "t"
+
+        if np.any(arb_grad_mask):
+            output_file.write("# Format of arbitrary gradients:\n")
+            output_file.write(
+                "#   time_shape_id of 0 means default timing (stepping with grad_raster starting at 1/2 of grad_raster)\n"
+            )
+            output_file.write("# id amplitude amp_shape_id time_shape_id delay\n")
+            output_file.write("# ..      Hz/m       ..         ..          us\n")
+            output_file.write("[GRADIENTS]\n")
+            id_format_str = "{:.0f} {:12g} {:.0f} {:.0f} {:.0f}\n"  # Refer lines 20-21
+            keys = np.array(list(self.grad_library.keys.keys()))
+            for k in keys[arb_grad_mask]:
+                s = id_format_str.format(
+                    k,
+                    *self.grad_library.data[k][:3],
+                    np.round(self.grad_library.data[k][3] * 1e6),
+                )
+                output_file.write(s)
+            output_file.write("\n")
+
+        if np.any(trap_grad_mask):
+            output_file.write("# Format of trapezoid gradients:\n")
+            output_file.write("# id amplitude rise flat fall delay\n")
+            output_file.write("# ..      Hz/m   us   us   us    us\n")
+            output_file.write("[TRAP]\n")
+            keys = np.array(list(self.grad_library.keys.keys()))
+            id_format_str = "{:2g} {:12g} {:3g} {:4g} {:3g} {:3g}\n"
+            for k in keys[trap_grad_mask]:
+                data = np.copy(
+                    self.grad_library.data[k]
+                )  # Make a copy to leave the original untouched
+                data[1:] = np.round(1e6 * data[1:])
+                """
+                Python & Numpy always round to nearest even value - inconsistent with MATLAB Pulseq's .seq files.
+                [1] https://stackoverflow.com/questions/29671945/format-string-rounding-inconsistent
+                [2] https://stackoverflow.com/questions/50374779/how-to-avoid-incorrect-rounding-with-numpy-round
+                """
+                s = id_format_str.format(k, *data)
+                output_file.write(s)
+            output_file.write("\n")
+
+        if len(self.adc_library.keys) != 0:
+            output_file.write("# Format of ADC events:\n")
+            output_file.write("# id num dwell delay freq phase\n")
+            output_file.write("# ..  ..    ns    us   Hz   rad\n")
+            output_file.write("[ADC]\n")
+            keys = self.adc_library.keys
+            id_format_str = (
+                "{:.0f} {:.0f} {:.0f} {:.0f} {:g} {:g}\n"  # Refer lines 20-21
+            )
+            for k in keys.values():
+                data = np.multiply(self.adc_library.data[k][0:5], [1, 1e9, 1e6, 1, 1])
+                s = id_format_str.format(k, *data)
+                output_file.write(s)
+            output_file.write("\n")
+
+        if len(self.extensions_library.keys) != 0:
+            output_file.write("# Format of extension lists:\n")
+            output_file.write("# id type ref next_id\n")
+            output_file.write("# next_id of 0 terminates the list\n")
+            output_file.write(
+                "# Extension list is followed by extension specifications\n"
+            )
+            output_file.write("[EXTENSIONS]\n")
+            keys = self.extensions_library.keys
+            id_format_str = "{:.0f} {:.0f} {:.0f} {:.0f}\n"  # Refer lines 20-21
+            for k in keys.values():
+                s = id_format_str.format(k, *np.round(self.extensions_library.data[k]))
+                output_file.write(s)
+            output_file.write("\n")
+
+        if len(self.trigger_library.keys) != 0:
+            output_file.write(
+                "# Extension specification for digital output and input triggers:\n"
+            )
+            output_file.write("# id type channel delay (us) duration (us)\n")
+            output_file.write(
+                f'extension TRIGGERS {self.get_extension_type_ID("TRIGGERS")}\n'
+            )
+            keys = self.trigger_library.keys
+            id_format_str = "{:.0f} {:.0f} {:.0f} {:.0f} {:.0f}\n"  # Refer lines 20-21
+            for k in keys.values():
+                s = id_format_str.format(
+                    k, *np.round(self.trigger_library.data[k] * [1, 1, 1e6, 1e6])
+                )
+                output_file.write(s)
+            output_file.write("\n")
+
+        if len(self.label_set_library.keys) != 0:
+            labels = get_supported_labels()
+
+            output_file.write("# Extension specification for setting labels:\n")
+            output_file.write("# id set labelstring\n")
+            tid = self.get_extension_type_ID("LABELSET")
+            output_file.write(f"extension LABELSET {tid}\n")
+            keys = self.label_set_library.keys
+            id_format_str = "{:.0f} {:.0f} {}\n"  # Refer lines 20-21
+            for k in keys.values():
+                value = self.label_set_library.data[k][0]
+                label_id = labels[
+                    int(self.label_set_library.data[k][1]) - 1
+                ]  # label_id is +1 in add_block()
+                s = id_format_str.format(k, value, label_id)
+                output_file.write(s)
+            output_file.write("\n")
+
+            output_file.write("# Extension specification for setting labels:\n")
+            output_file.write("# id set labelstring\n")
+            tid = self.get_extension_type_ID("LABELINC")
+            output_file.write(f"extension LABELINC {tid}\n")
+            keys = self.label_inc_library.keys
+            id_format_str = "{:.0f} {:.0f} {}\n"  # See comment at the beginning of this method definition
+            for k in keys.values():
+                value = self.label_inc_library.data[k][0]
+                label_id = labels[
+                    self.label_inc_library.data[k][1] - 1
+                ]  # label_id is +1 in add_block()
+                s = id_format_str.format(k, value, label_id)
+                output_file.write(s)
+            output_file.write("\n")
+
+        if len(self.shape_library.keys) != 0:
+            output_file.write("# Sequence Shapes\n")
+            output_file.write("[SHAPES]\n\n")
+            keys = self.shape_library.keys
+            for k in keys.values():
+                shape_data = self.shape_library.data[k]
+                s = "shape_id {:.0f}\n".format(k)
+                output_file.write(s)
+                s = "num_samples {:.0f}\n".format(shape_data[0])
+                output_file.write(s)
+                s = ("{:.9g}\n" * len(shape_data[1:])).format(*shape_data[1:])
+                output_file.write(s)
+                output_file.write("\n")
+
+    if create_signature:  # Sign the file
+        # Calculate digest
+        with open(file_name, "r") as output_file:
+            buffer = output_file.read()
+
+            md5 = hashlib.md5(buffer.encode("utf-8")).hexdigest()
+            self.signature_type = "md5"
+            self.signature_file = "text"
+            self.signature_value = md5
+
+        # Write signature
+        with open(file_name, "a") as output_file:
+            output_file.write("\n[SIGNATURE]\n")
+            output_file.write(
+                "# This is the hash of the Pulseq file, calculated right before the [SIGNATURE] section was added\n"
+            )
+            output_file.write(
+                "# It can be reproduced/verified with md5sum if the file trimmed to the position right above [SIGNATURE]\n"
+            )
+            output_file.write(
+                "# The new line character preceding [SIGNATURE] BELONGS to the signature (and needs to be stripped away for "
+                "recalculating/verification)\n"
+            )
+            output_file.write("Type md5\n")
+            output_file.write(f"Hash {md5}\n")

+ 55 - 0
libs/lf-scanner/pypulseq/__init__.py

@@ -0,0 +1,55 @@
+import numpy as np
+import os
+import sys
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'pypulseq')))
+
+# =========
+# BANKER'S ROUNDING FIX
+# =========
+def round_half_up(n, decimals=0):
+    """
+    Avoid banker's rounding inconsistencies; from https://realpython.com/python-rounding/#rounding-half-up
+    """
+    multiplier = 10**decimals
+    return np.floor(np.abs(n) * multiplier + 0.5) / multiplier
+
+
+# =========
+# NP.FLOAT EPSILON
+# =========
+eps = np.finfo(np.float64).eps
+
+# =========
+# PACKAGE-LEVEL IMPORTS
+# =========
+from LF_scanner.pypulseq.SAR.SAR_calc import calc_SAR
+from LF_scanner.pypulseq.Sequence.sequence import Sequence
+from LF_scanner.pypulseq.add_gradients import add_gradients
+from LF_scanner.pypulseq.align import align
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.calc_ramp import calc_ramp
+from LF_scanner.pypulseq.calc_rf_bandwidth import calc_rf_bandwidth
+from LF_scanner.pypulseq.calc_rf_center import calc_rf_center
+from LF_scanner.pypulseq.make_adc import make_adc
+from LF_scanner.pypulseq.make_adiabatic_pulse import make_adiabatic_pulse
+from LF_scanner.pypulseq.make_arbitrary_rf import make_arbitrary_rf
+from LF_scanner.pypulseq.make_block_pulse import make_block_pulse
+from LF_scanner.pypulseq.make_sigpy_pulse import *
+from LF_scanner.pypulseq.make_delay import make_delay
+from LF_scanner.pypulseq.make_digital_output_pulse import make_digital_output_pulse
+from LF_scanner.pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from LF_scanner.pypulseq.make_extended_trapezoid_area import make_extended_trapezoid_area
+from LF_scanner.pypulseq.make_gauss_pulse import make_gauss_pulse
+from LF_scanner.pypulseq.make_label import make_label
+from LF_scanner.pypulseq.make_sinc_pulse import make_sinc_pulse
+from LF_scanner.pypulseq.make_trapezoid import make_trapezoid
+from LF_scanner.pypulseq.sigpy_pulse_opts import SigpyPulseOpts
+from LF_scanner.pypulseq.make_trigger import make_trigger
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.points_to_waveform import points_to_waveform
+from LF_scanner.pypulseq.rotate import rotate
+from LF_scanner.pypulseq.scale_grad import scale_grad
+from LF_scanner.pypulseq.split_gradient import split_gradient
+from LF_scanner.pypulseq.split_gradient_at import split_gradient_at
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_labels
+from LF_scanner.pypulseq.traj_to_grad import traj_to_grad

+ 222 - 0
libs/lf-scanner/pypulseq/add_gradients.py

@@ -0,0 +1,222 @@
+from copy import deepcopy
+from types import SimpleNamespace
+from typing import Iterable
+
+import numpy as np
+
+from LF_scanner.pypulseq import eps
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.make_arbitrary_grad import make_arbitrary_grad
+from LF_scanner.pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.points_to_waveform import points_to_waveform
+
+
+def add_gradients(
+    grads: Iterable[SimpleNamespace],
+    max_grad: int = 0,
+    max_slew: int = 0,
+    system=Opts(),
+) -> SimpleNamespace:
+    """
+    Returns the superposition of several gradients.
+
+    Parameters
+    ----------
+    grads : [SimpleNamespace, ...]
+        Gradient events.
+    system : Opts, default=Opts()
+        System limits.
+    max_grad : float, default=0
+        Maximum gradient amplitude.
+    max_slew : float, default=0
+        Maximum slew rate.
+
+    Returns
+    -------
+    grad : SimpleNamespace
+        Superimposition of gradient events from `grads`.
+    """
+    # copy() to emulate pass-by-value; otherwise passed grad events are modified
+    grads = deepcopy(grads)
+
+    if max_grad <= 0:
+        max_grad = system.max_grad
+    if max_slew <= 0:
+        max_slew = system.max_slew
+
+    if len(grads) < 2:
+        raise ValueError("Cannot add less than two gradients")
+
+    # First gradient defines channel
+    channel = grads[0].channel
+
+    # Find out the general delay of all gradients and other statistics
+    delays, firsts, lasts, durs, is_trap, is_arb = [], [], [], [], [], []
+    for ii in range(len(grads)):
+        if grads[ii].channel != channel:
+            raise ValueError("Cannot add gradients on different channels.")
+
+        delays.append(grads[ii].delay)
+        firsts.append(grads[ii].first)
+        lasts.append(grads[ii].last)
+        durs.append(calc_duration(grads[ii]))
+        is_trap.append(grads[ii].type == "trap")
+        if is_trap[-1]:
+            is_arb.append(False)
+        else:
+            tt_rast = grads[ii].tt / system.grad_raster_time + 0.5
+            is_arb.append(np.all(np.abs(tt_rast - np.arange(len(tt_rast)))) < eps)
+
+    # Convert to numpy.ndarray for fancy-indexing later on
+    firsts, lasts = np.array(firsts), np.array(lasts)
+
+    common_delay = np.min(delays)
+    total_duration = np.max(durs)
+
+    # Check if we have a set of traps with the same timing
+    if np.all(is_trap):
+        cond1 = 1 == len(np.unique([g.delay for g in grads]))
+        cond2 = 1 == len(np.unique([g.rise_time for g in grads]))
+        cond3 = 1 == len(np.unique([g.flat_time for g in grads]))
+        cond4 = 1 == len(np.unique([g.fall_time for g in grads]))
+        if cond1 and cond2 and cond3 and cond4:
+            grad = grads[0]
+            grad.amplitude = np.sum([g.amplitude for g in grads])
+            grad.area = np.sum([g.area for g in grads])
+            grad.flat_area = np.sum([g.flat_area for g in grads])
+
+            return grad
+
+    # Check if we only have arbitrary grads on irregular time samplings, optionally mixed with trapezoids
+    if np.all(np.logical_or(is_trap, np.logical_not(is_arb))):
+        # Keep shapes still rather simple
+        times = []
+        for ii in range(len(grads)):
+            g = grads[ii]
+            if g.type == "trap":
+                times.extend(
+                    np.cumsum([g.delay, g.rise_time, g.flat_time, g.fall_time])
+                )
+            else:
+                times.extend(g.delay + g.tt)
+
+        times = np.sort(np.unique(times))
+        dt = times[1:] - times[:-1]
+        ieps = dt < eps
+        if np.any(ieps):
+            dtx = [times[0], *dt]
+            dtx[ieps] = (
+                dtx[ieps] + dtx[ieps + 1]
+            )  # Assumes that no more than two too similar values can occur
+            dtx[ieps + 1] = []
+            times = np.cumsum(dtx)
+
+        amplitudes = np.zeros_like(times)
+        for ii in range(len(grads)):
+            g = grads[ii]
+            if g.type == "trap":
+                if g.flat_time > 0:  # Trapezoid or triangle
+                    g.tt = np.cumsum([0, g.rise_time, g.flat_time, g.fall_time])
+                    g.waveform = [0, g.amplitude, g.amplitude, 0]
+                else:
+                    g.tt = np.cumsum([0, g.rise_time, g.fall_time])
+                    g.waveform = [0, g.amplitude, 0]
+
+            tt = g.delay + g.tt
+            # Fix rounding for the first and last time points
+            i_min = np.argmin(np.abs(tt[0] - times))
+            t_min = (np.abs(tt[0] - times))[i_min]
+            if t_min < eps:
+                tt[0] = times[i_min]
+            i_min = np.argmin(np.abs(tt[-1] - times))
+            t_min = (np.abs(tt[-1] - times))[i_min]
+            if t_min < eps:
+                tt[-1] = times[i_min]
+
+            if np.abs(g.waveform[0]) > eps and tt[0] > eps:
+                tt[0] += eps
+
+            amplitudes += np.interp(xp=tt, fp=g.waveform, x=times)
+
+        grad = make_extended_trapezoid(
+            channel=channel, amplitudes=amplitudes, times=times, system=system
+        )
+        return grad
+
+    # Convert everything to a regularly-sampled waveform
+    waveforms = dict()
+    max_length = 0
+    for ii in range(len(grads)):
+        g = grads[ii]
+        if g.type == "grad":
+            if is_arb[ii]:
+                waveforms[ii] = g.waveform
+            else:
+                waveforms[ii] = points_to_waveform(
+                    amplitudes=g.waveform,
+                    times=g.tt,
+                    grad_raster_time=system.grad_raster_time,
+                )
+        elif g.type == "trap":
+            if g.flat_time > 0:  # Triangle or trapezoid
+                times = np.array(
+                    [
+                        g.delay - common_delay,
+                        g.delay - common_delay + g.rise_time,
+                        g.delay - common_delay + g.rise_time + g.flat_time,
+                        g.delay
+                        - common_delay
+                        + g.rise_time
+                        + g.flat_time
+                        + g.fall_time,
+                    ]
+                )
+                amplitudes = np.array([0, g.amplitude, g.amplitude, 0])
+            else:
+                times = np.array(
+                    [
+                        g.delay - common_delay,
+                        g.delay - common_delay + g.rise_time,
+                        g.delay - common_delay + g.rise_time + g.fall_time,
+                    ]
+                )
+                amplitudes = np.array([0, g.amplitude, 0])
+            waveforms[ii] = points_to_waveform(
+                amplitudes=amplitudes,
+                times=times,
+                grad_raster_time=system.grad_raster_time,
+            )
+        else:
+            raise ValueError("Unknown gradient type")
+
+        if g.delay - common_delay > 0:
+            # Stop for numpy.arange is not g.delay - common_delay - system.grad_raster_time like in Matlab
+            # so as to include the endpoint
+            t_delay = np.arange(0, g.delay - common_delay, step=system.grad_raster_time)
+            waveforms[ii] = np.insert(waveforms[ii], 0, t_delay)
+
+        num_points = len(waveforms[ii])
+        max_length = num_points if num_points > max_length else max_length
+
+    w = np.zeros(max_length)
+    for ii in range(len(grads)):
+        wt = np.zeros(max_length)
+        wt[0 : len(waveforms[ii])] = waveforms[ii]
+        w += wt
+
+    grad = make_arbitrary_grad(
+        channel=channel,
+        waveform=w,
+        system=system,
+        max_slew=max_slew,
+        max_grad=max_grad,
+        delay=common_delay,
+    )
+    # Fix the first and the last values
+    # First is defined by the sum of firsts with the minimal delay (common_delay)
+    # Last is defined by the sum of lasts with the maximum duration (total_duration)
+    grad.first = np.sum(firsts[np.array(delays) == common_delay])
+    grad.last = np.sum(lasts[np.where(durs == total_duration)])
+
+    return grad

+ 92 - 0
libs/lf-scanner/pypulseq/add_ramps.py

@@ -0,0 +1,92 @@
+from copy import copy
+from types import SimpleNamespace
+from typing import Union, List
+
+import numpy as np
+
+from pypulseq.calc_ramp import calc_ramp
+from pypulseq.opts import Opts
+
+
+def add_ramps(
+    k: Union[list, np.ndarray, tuple],
+    max_grad: int = 0,
+    max_slew: int = 0,
+    rf: SimpleNamespace = None,
+    system=Opts(),
+) -> List[np.ndarray]:
+    """
+    Add segments to the trajectory to ramp to and from the given trajectory.
+
+    Parameters
+    ----------
+    k : numpy.ndarray, or [numpy.ndarray, ...]
+        If `k` is a single trajectory: Add a segment to `k` so `k_out` travels from 0 to `k[0]` and a segment so `k_out`
+        goes from `k[-1]` back to 0 without violating the gradient and slew constraints.
+        If `k` is multiple trajectoriess: add segments of the same length for each trajectory in the cell array.
+    system : Opts, default=Opts()
+        System limits.
+    rf : SimpleNamespace, default=None
+        Add a segment of zeros over the ramp times to an RF shape.
+    max_grad : int, default=0
+        Maximum gradient amplitude.
+    max_slew : int, default=0
+        Maximum slew rate.
+
+    Returns
+    -------
+    result : [numpy.ndarray, ...]
+        List of ramped up and ramped down k-space trajectories from `k`.
+
+    Raises
+    ------
+    ValueError
+        If `k` is not list, np.ndarray or tuple
+    RuntimeError
+        If gradient ramps fail to be calculated
+    """
+    if not isinstance(k, (list, np.ndarray, tuple)):
+        raise ValueError(
+            f"k has to be one of list, np.ndarray, tuple. Passed: {type(k)}"
+        )
+
+    k_arg = copy(k)
+    if max_grad > 0:
+        system.max_grad = max_grad
+
+    if max_slew > 0:
+        system.max_slew = max_slew
+
+    k = np.vstack(k)
+    num_channels = k.shape[0]
+    k = np.vstack(
+        (k, np.zeros((3 - num_channels, k.shape[1])))
+    )  # Pad with zeros if needed
+
+    k_up, ok1 = calc_ramp(k0=np.zeros((3, 2)), k_end=k[:, :2], system=system)
+    k_down, ok2 = calc_ramp(k0=k[:, -2:], k_end=np.zeros((3, 2)), system=system)
+    if not (ok1 and ok2):
+        raise RuntimeError("Failed to calculate gradient ramps")
+
+    # Add start and end points to ramps
+    k_up = np.hstack((np.zeros((3, 2)), k_up))
+    k_down = np.hstack((k_down, np.zeros((3, 1))))
+
+    # Add ramps to trajectory
+    k = np.hstack((k_up, k, k_down))
+
+    result = []
+    if not isinstance(k_arg, list):
+        result.append(k[:num_channels])
+    else:
+        for i in range(num_channels):
+            result.append(k[i])
+
+    if rf is not None:
+        result.append(
+            np.concatenate(
+                (np.zeros(k_up.shape[1] * 10), rf, np.zeros(k_down.shape[1] * 10))
+            )
+        )
+
+    return result

+ 81 - 0
libs/lf-scanner/pypulseq/align.py

@@ -0,0 +1,81 @@
+from copy import deepcopy
+from types import SimpleNamespace
+from typing import List, Union
+
+import numpy as np
+
+from LF_scanner.pypulseq.calc_duration import calc_duration
+
+
+def align(
+    **kwargs: Union[SimpleNamespace, List[SimpleNamespace]]
+) -> List[SimpleNamespace]:
+    """
+    Sets delays of the objects within the block to achieve the desired alignment of the objects in the block. Aligns
+    objects as per specified alignment options by setting delays of the pulse sequence events within the block. All
+    previously configured delays within objects are taken into account during calculating of the block duration but
+    then reset according to the selected alignment. Possible values for align_spec are 'left', 'center', 'right'.
+
+    Parameters
+    ----------
+    args : dict{str, [SimpleNamespace, ...]}
+        Dictionary mapping of alignment options and `SimpleNamespace` objects.
+        Format: alignment_spec1=SimpleNamespace, alignment_spec2=[SimpleNamespace, ...], ...
+        Alignment spec must be one of `left`, `center` or `right`.
+
+    Returns
+    -------
+    objects : [SimpleNamespace, ...]
+        List of aligned `SimpleNamespace` objects.
+
+    Raises
+    ------
+    ValueError
+        If first parameter is not of type `str`.
+        If invalid alignment spec is passed. Must be one of `left`, `center` or `right`.
+
+    Examples
+    --------
+    al_grad1, al_grad2, al_grad3 = align(right=[grad1, grad2, grad3])
+    """
+    alignment_specs = list(kwargs.keys())
+    if not isinstance(alignment_specs[0], str):
+        raise ValueError(
+            f"First parameter must be of type str. Passed: {type(alignment_specs[0])}"
+        )
+
+    alignment_options = ["left", "center", "right"]
+    if np.any([align_opt not in alignment_options for align_opt in alignment_specs]):
+        raise ValueError("Invalid alignment spec.")
+
+    alignments = []
+    objects = []
+    for curr_align in alignment_specs:
+        objects_to_align = kwargs[curr_align]
+        curr_align = alignment_options.index(curr_align)
+        if isinstance(objects_to_align, (list, np.ndarray, tuple)):
+            alignments.extend([curr_align] * len(objects_to_align))
+            objects.extend(objects_to_align)
+        elif isinstance(objects_to_align, SimpleNamespace):
+            alignments.extend([curr_align])
+            objects.append(objects_to_align)
+
+    dur = calc_duration(*objects)
+
+    # copy() to emulate pass-by-value; otherwise passed events are modified
+    objects = deepcopy(objects)
+
+    # Set new delays
+    for i in range(len(objects)):
+        if alignments[i] == 0:
+            objects[i].delay = 0
+        elif alignments[i] == 1:
+            objects[i].delay = (dur - calc_duration(objects[i])) / 2
+        elif alignments[i] == 2:
+            objects[i].delay = dur - calc_duration(objects[i]) + objects[i].delay
+            if objects[i].delay < 0:
+                raise ValueError(
+                    "align() attempts to set a negative delay, probably some RF pulses ignore rf_ringdown_time"
+                )
+
+    return objects

+ 53 - 0
libs/lf-scanner/pypulseq/block_to_events.py

@@ -0,0 +1,53 @@
+from types import SimpleNamespace
+from typing import Tuple
+
+
+def block_to_events(*args: SimpleNamespace) -> Tuple[SimpleNamespace, ...]:
+    """
+    Converts `args` from a block to a list of events. If `args` is already a list of event(s), returns it unmodified.
+
+    Parameters
+    ----------
+    args : SimpleNamespace
+        Block to be flattened into a list of events.
+
+    Returns
+    -------
+    events : list[SimpleNamespace]
+        List of events comprising `args` if it was a block, otherwise `args` unmodified.
+    """
+    if (
+        len(args) == 1
+    ):  # args is a tuple consisting a block of events, or a single event
+        x = args[0]
+
+        if isinstance(x, (float, int)):  # args is block duration
+            events = [x]
+        else:  # args could be a block of events or a single event
+            events = list(vars(x).values())  # Get all attrs
+            events = list(
+                filter(lambda filter_none: filter_none is not None, events)
+            )  # Filter None attributes
+            # If all attrs are either float/SimpleNamespace, args is a block of events
+            if all([isinstance(e, (float, SimpleNamespace)) for e in events]):
+                events = __get_label_events_if_any(
+                    *events
+                )  # Flatten label events from dict datatype
+            else:  # Else, args is a single event
+                events = [x]
+    else:  # args is a tuple of events
+        events = __get_label_events_if_any(*args)
+
+    return events
+
+
+def __get_label_events_if_any(*events: list) -> list:
+    # Are any of the events labels? If yes, extract them from dict()
+    final_events = []
+    for e in events:
+        if isinstance(e, dict):  # Only labels are stored as dicts
+            final_events.extend(e.values())
+        else:
+            final_events.append(e)
+
+    return final_events

+ 61 - 0
libs/lf-scanner/pypulseq/calc_duration.py

@@ -0,0 +1,61 @@
+from types import SimpleNamespace
+
+import numpy as np
+
+from LF_scanner.pypulseq.block_to_events import block_to_events
+
+
+def calc_duration(*args: SimpleNamespace) -> float:
+    """
+    Calculate the duration of an event or block.
+
+    Parameters
+    ----------
+    args : SimpleNamespace
+        Block or events.
+
+    Returns
+    -------
+    duration : float
+        Cumulative duration of `args`.
+    """
+    events = block_to_events(*args)
+
+    duration = 0
+    for event in events:
+        if isinstance(event, (float, int)):  # block_duration field
+            assert duration <= event
+            duration = event
+            continue
+
+        if not isinstance(event, (dict, SimpleNamespace)):
+            raise TypeError(
+                "input(s) should be of type SimpleNamespace or a dict() in case of LABELINC or LABELSET"
+            )
+
+        if event.type == "delay":
+            duration = np.max([duration, event.delay])
+        elif event.type == "rf":
+            duration = np.max(
+                [duration, event.delay + event.shape_dur + event.ringdown_time]
+            )
+        elif event.type == "grad":
+            duration = np.max([duration, event.delay + event.shape_dur])
+        elif event.type == "adc":
+            duration = np.max(
+                [
+                    duration,
+                    event.delay + event.num_samples * event.dwell + event.dead_time,
+                ]
+            )
+        elif event.type == "trap":
+            duration = np.max(
+                [
+                    duration,
+                    event.delay + event.rise_time + event.flat_time + event.fall_time,
+                ]
+            )
+        elif event.type == "output" or event.type == "trigger":
+            duration = np.max([duration, event.delay + event.duration])
+
+    return duration

+ 355 - 0
libs/lf-scanner/pypulseq/calc_ramp.py

@@ -0,0 +1,355 @@
+from typing import Tuple
+
+import numpy as np
+
+from LF_scanner.pypulseq.opts import Opts
+
+
+def calc_ramp(
+    k0: np.ndarray,
+    k_end: np.ndarray,
+    max_grad: np.ndarray = np.zeros(0),
+    max_points: int = 500,
+    max_slew: np.ndarray = np.zeros(0),
+    system: Opts = Opts(),
+) -> Tuple[np.ndarray, bool]:
+    """
+    Join the points `k0` and `k_end` in three-dimensional  k-space in minimal time, observing the gradient and slew
+    limits (`max_grad` and `max_slew` respectively), and the gradient strength `G0` before `k0[:, 1]` and `Gend` after
+    `k_end[:, 1]`. In the context of a fixed gradient dwell time this is a discrete problem with an a priori unknown
+    number of discretization steps. Therefore this method tries out the optimization with 0 steps, then 1 step, and so
+    on, until  all conditions can be fulfilled, thus yielding a short connection.
+
+    Parameters
+    ----------
+    k0 : numpy.ndarray
+        Two preceding points in k-space. Shape is `[3, 2]`. From these points, the starting gradient will be calculated.
+    k_end : numpy.ndarray
+        Two following points in k-space. Shape is `[3, 2]`. From these points, the target gradient will be calculated.
+    max_grad : float or array_like, default=0
+        Maximum total gradient strength. Either a single value or one value for each coordinate, of shape `[3, 1]`.
+    max_points : int, default=500
+        Maximum number of k-space points to be used in connecting `k0` and `k_end`.
+    max_slew : float or array_like, default=0
+        Maximum total slew rate. Either a single value or one value for each coordinate, of shape `[3, 1]`.
+    system : Opts, default=Opts()
+        System limits.
+
+    Returns
+    -------
+    k_out : numpy.ndarray
+        Connected k-space trajectory.
+    success : bool
+        Boolean flag indicating if `k0` and `k_end` were successfully joined.
+    """
+
+    def __inside_limits(grad, slew):
+        if mode == 0:
+            grad2 = np.sum(np.square(grad), axis=1)
+            slew2 = np.sum(np.square(slew), axis=1)
+            ok = np.all(np.max(grad2) <= np.square(max_grad)) and np.all(
+                np.max(slew2) <= np.square(max_slew)
+            )
+        else:
+            ok = (np.sum(np.max(np.abs(grad), axis=1) <= max_grad) == 3) and (
+                np.sum(np.max(np.abs(slew), axis=1) <= max_slew) == 3
+            )
+
+        return ok
+
+    def __joinleft0(k0, k_end, use_points, G0, G_end):
+        if use_points == 0:
+            G = np.stack((G0, (k_end - k0) / grad_raster, G_end)).T
+            S = (G[:, 1:] - G[:, :-1]) / grad_raster
+
+            k_out_left = np.zeros((3, 0))
+            success = __inside_limits(G, S)
+
+            return success, k_out_left
+
+        dk = (k_end - k0) / (use_points + 1)
+        kopt = k0 + dk
+        Gopt = (kopt - k0) / grad_raster
+        Sopt = (Gopt - G0) / grad_raster
+
+        okGopt = np.sum(np.square(Gopt)) <= np.square(max_grad)
+        okSopt = np.sum(np.square(Sopt)) <= np.square(max_slew)
+
+        if okGopt and okSopt:
+            k_left = kopt
+        else:
+            a = np.multiply(max_grad, grad_raster)
+            b = np.multiply(max_slew, grad_raster**2)
+
+            dkprol = G0 * grad_raster
+            dkconn = dk - dkprol
+
+            ksl = k0 + dkprol + dkconn / np.linalg.norm(dkconn) * b
+            Gsl = (ksl - k0) / grad_raster
+            okGsl = np.sum(np.square(Gsl)) <= np.square(max_grad)
+
+            kgl = k0 + np.multiply(dk / np.linalg.norm(dk), a)
+            Ggl = (kgl - k0) / grad_raster
+            Sgl = (Ggl - G0) / grad_raster
+            okSgl = np.sum(np.square(Sgl)) <= np.square(max_slew)
+
+            if okGsl:
+                k_left = ksl
+            elif okSgl:
+                k_left = kgl
+            else:
+                c = np.linalg.norm(dkprol)
+                c1 = np.divide(np.square(a) - np.square(b) + np.square(c), (2 * c))
+                h = np.sqrt(np.square(a) - np.square(c1))
+                kglsl = k0 + np.multiply(c1, np.divide(dkprol, np.linalg.norm(dkprol)))
+                projondkprol = (kgl * dkprol.T) * (dkprol / np.linalg.norm(dkprol))
+                hdirection = kgl - projondkprol
+                kglsl = kglsl + h * hdirection / np.linalg.norm(hdirection)
+                k_left = kglsl
+
+        success, k = __joinright0(
+            k_left, k_end, (k_left - k0) / grad_raster, G_end, use_points - 1
+        )
+        if len(k) != 0:
+            if len(k.shape) == 1:
+                k = k.reshape((len(k), 1))
+            if len(k_left.shape) == 1:
+                k_left = k_left.reshape((len(k_left), 1))
+            k_out_left = np.hstack((k_left, k))
+        else:
+            k_out_left = k_left
+
+        return success, k_out_left
+
+    def __joinleft1(k0, k_end, use_points, G0, G_end):
+        if use_points == 0:
+            G = np.stack((G0, (k_end - k0) / grad_raster, G_end))
+            S = (G[:, 1:] - G[:, :-1]) / grad_raster
+
+            k_out_left = np.zeros((3, 0))
+            success = __inside_limits(G, S)
+
+            return success, k_out_left
+
+        k_left = np.zeros(3)
+
+        dk = (k_end - k0) / (use_points + 1)
+        kopt = k0 + dk
+        Gopt = (kopt - k0) / grad_raster
+        Sopt = (Gopt - G0) / grad_raster
+
+        okGopt = np.abs(Gopt) <= max_grad
+        okSopt = np.abs(Sopt) <= max_slew
+
+        dkprol = G0 * grad_raster
+        dkconn = dk - dkprol
+
+        ksl = k0 + dkprol + np.multiply(np.sign(dkconn), max_slew) * grad_raster**2
+        Gsl = (ksl - k0) / grad_raster
+        okGsl = np.abs(Gsl) <= max_grad
+
+        kgl = k0 + np.multiply(np.sign(dk), max_grad) * grad_raster**2
+        Ggl = (kgl - k0) / grad_raster
+        Sgl = (Ggl - G0) / grad_raster
+        okSgl = np.abs(Sgl) <= max_slew
+
+        for ii in range(3):
+            if okGopt[ii] == 1 and okSopt[ii] == 1:
+                k_left[ii] = kopt[ii]
+            elif okGsl[ii] == 1:
+                k_left[ii] = ksl[ii]
+            elif okSgl[ii] == 1:
+                k_left[ii] = kgl[ii]
+            else:
+                print("Unknown error")
+
+        success, k = __joinright1(
+            k0=k_left,
+            k_end=k_end,
+            use_points=use_points - 1,
+            G0=(k_left - k0) / grad_raster,
+            G_end=G_end,
+        )
+        if len(k) != 0:
+            if len(k.shape) == 1:
+                k = k.reshape((len(k), 1))
+            if len(k_left.shape) == 1:
+                k_left = k_left.reshape((len(k_left), 1))
+            k_out_left = np.hstack((k_left, k))
+        else:
+            k_out_left = k_left
+
+        return success, k_out_left
+
+    def __joinright0(k0, k_end, use_points, G0, G_end):
+        if use_points == 0:
+            G = np.stack((G0, (k_end - k0) / grad_raster, G_end)).T
+            S = (G[:, 1:] - G[:, :-1]) / grad_raster
+
+            k_out_right = np.zeros((3, 0))
+            success = __inside_limits(G, S)
+
+            return success, k_out_right
+
+        dk = (k0 - k_end) / (use_points + 1)
+        kopt = k_end + dk
+        Gopt = (k_end - kopt) / grad_raster
+        Sopt = (G_end - Gopt) / grad_raster
+
+        okGopt = np.sum(np.square(Gopt)) <= np.square(max_grad)
+        okSopt = np.sum(np.square(Sopt)) <= np.square(max_slew)
+
+        if okGopt and okSopt:
+            k_right = kopt
+        else:
+            a = np.multiply(max_grad, grad_raster)
+            b = np.multiply(max_slew, grad_raster**2)
+
+            dkprol = -G_end * grad_raster
+            dkconn = dk - dkprol
+
+            ksl = k_end + dkprol + dkconn / np.linalg.norm(dkconn) * b
+            Gsl = (k_end - ksl) / grad_raster
+            okGsl = np.sum(np.square(Gsl)) <= np.square(max_grad)
+
+            kgl = k_end + np.multiply(dk / np.linalg.norm(dk), a)
+            Ggl = (k_end - kgl) / grad_raster
+            Sgl = (G_end - Ggl) / grad_raster
+            okSgl = np.sum(np.square(Sgl)) <= np.square(max_slew)
+
+            if okGsl:
+                k_right = ksl
+            elif okSgl:
+                k_right = kgl
+            else:
+                c = np.linalg.norm(dkprol)
+                c1 = np.divide(np.square(a) - np.square(b) + np.square(c), (2 * c))
+                h = np.sqrt(np.square(a) - np.square(c1))
+                kglsl = k_end + np.multiply(
+                    c1, np.divide(dkprol, np.linalg.norm(dkprol))
+                )
+                projondkprol = (kgl * dkprol.T) * (dkprol / np.linalg.norm(dkprol))
+                hdirection = kgl - projondkprol
+                kglsl = kglsl + h * hdirection / np.linalg.norm(hdirection)
+                k_right = kglsl
+
+        success, k = __joinleft0(
+            k0=k0,
+            k_end=k_right,
+            G0=G0,
+            G_end=(k_end - k_right) / grad_raster,
+            use_points=use_points - 1,
+        )
+        if len(k) != 0:
+            if len(k.shape) == 1:
+                k = k.reshape((len(k), 1))
+            if len(k_right.shape) == 1:
+                k_right = k_right.reshape((len(k_right), 1))
+            k_out_right = np.hstack((k, k_right))
+        else:
+            k_out_right = k_right
+
+        return success, k_out_right
+
+    def __joinright1(k0, k_end, use_points, G0, G_end):
+        if use_points == 0:
+            G = np.stack((G0, (k_end - k0) / grad_raster, G_end))
+            S = (G[:, 1:] - G[:, :-1]) / grad_raster
+
+            k_out_right = np.zeros((3, 0))
+            success = __inside_limits(G, S)
+
+            return success, k_out_right
+
+        k_right = np.zeros(3)
+
+        dk = (k0 - k_end) / (use_points + 1)
+        kopt = k_end + dk
+        Gopt = (k_end - kopt) / grad_raster
+        Sopt = (G_end - Gopt) / grad_raster
+
+        okGopt = np.abs(Gopt) <= max_grad
+        okSopt = np.abs(Sopt) <= max_slew
+
+        dkprol = -G_end * grad_raster
+        dkconn = dk - dkprol
+
+        ksl = k_end + dkprol + np.multiply(np.sign(dkconn), max_slew) * grad_raster**2
+        Gsl = (k_end - ksl) / grad_raster
+        okGsl = np.abs(Gsl) <= max_grad
+
+        kgl = k_end + np.multiply(np.sign(dk), max_grad) * grad_raster
+        Ggl = (k_end - kgl) / grad_raster
+        Sgl = (G_end - Ggl) / grad_raster
+        okSgl = np.abs(Sgl) <= max_slew
+
+        for ii in range(3):
+            if okGopt[ii] == 1 and okSopt[ii] == 1:
+                k_right[ii] = kopt[ii]
+            elif okGsl[ii] == 1:
+                k_right[ii] = ksl[ii]
+            elif okSgl[ii] == 1:
+                k_right[ii] = kgl[ii]
+            else:
+                print("Unknown error")
+
+        success, k = __joinleft1(
+            k0=k0,
+            k_end=k_right,
+            use_points=use_points - 1,
+            G0=G0,
+            G_end=(k_end - k_right) / grad_raster,
+        )
+        if len(k) != 0:
+            if len(k.shape) == 1:
+                k = k.reshape((len(k), 1))
+            if len(k_right.shape) == 1:
+                k_right = k_right.reshape((len(k_right), 1))
+            k_out_right = np.hstack((k, k_right))
+        else:
+            k_out_right = k_right
+
+        return success, k_out_right
+
+    # =========
+    # MAIN FUNCTION
+    # =========
+    if np.all(np.where(max_grad <= 0)):
+        max_grad = [system.max_grad]
+    if np.all(np.where(max_slew <= 0)):
+        max_slew = [system.max_slew]
+
+    grad_raster = system.grad_raster_time
+
+    if len(max_grad) == 1 and len(max_slew) == 1:
+        mode = 0
+    elif len(max_grad) == 3 and len(max_slew) == 3:
+        mode = 1
+    else:
+        raise ValueError("Input value max grad or max slew in invalid format.")
+
+    G0 = (k0[:, 1] - k0[:, 0]) / grad_raster
+    G_end = (k_end[:, 1] - k_end[:, 0]) / grad_raster
+    k0 = k0[:, 1]
+    k_end = k_end[:, 0]
+
+    success = 0
+    k_out = np.zeros((3, 0))
+    use_points = 0
+
+    while success == 0 and use_points <= max_points:
+        if mode == 0:
+            if np.linalg.norm(G0) > max_grad or np.linalg.norm(G_end) > max_grad:
+                break
+            success, k_out = __joinleft0(
+                k0=k0, k_end=k_end, G0=G0, G_end=G_end, use_points=use_points
+            )
+        else:
+            if np.abs(G0) > np.abs(max_grad) or np.abs(G_end) > np.abs(max_grad):
+                break
+            success, k_out = __joinleft1(
+                k0=k0, k_end=k_end, use_points=use_points, G0=G0, G_end=G_end
+            )
+        use_points += 1
+
+    return k_out, success

+ 65 - 0
libs/lf-scanner/pypulseq/calc_rf_bandwidth.py

@@ -0,0 +1,65 @@
+from types import SimpleNamespace
+from typing import Union, Tuple
+
+import numpy as np
+
+from LF_scanner.pypulseq.calc_rf_center import calc_rf_center
+
+
+def calc_rf_bandwidth(
+    rf: SimpleNamespace,
+    cutoff: float = 0.5,
+    return_axis: bool = False,
+    return_spectrum: bool = False,
+) -> Union[float, Tuple[float, np.ndarray], Tuple[float, np.ndarray, float]]:
+    """
+    Calculate the spectrum of the RF pulse. Returns the bandwidth of the pulse (calculated by a simple FFT, e.g.
+    presuming a low-angle approximation) and optionally the spectrum and the frequency axis. The default for the
+    optional parameter 'cutoff' is 0.5.
+
+    Parameters
+    ----------
+    rf : SimpleNamespace
+        RF pulse event.
+    cutoff : float, default=0.5
+    return_axis : bool, default=False
+        Boolean flag to indicate if frequency axis of RF pulse will be returned.
+    return_spectrum : bool, default=False
+        Boolean flag to indicate if spectrum of RF pulse will be returned.
+    Returns
+    -------
+    bw : float
+        Bandwidth of the RF pulse.
+
+    """
+    time_center, _ = calc_rf_center(rf)
+
+    # Resample the pulse to a reasonable time array
+    dw = 10  # Hz
+    dt = 1e-6  # For now, 1 MHz
+    nn = np.round(1 / dw / dt)
+    tt = np.arange(-np.floor(nn / 2), np.ceil(nn / 2) - 1) * dt
+
+    rfs = np.interp(xp=rf.t - time_center, fp=rf.signal, x=tt)
+    spectrum = np.fft.fftshift(np.fft.fft(np.fft.fftshift(rfs)))
+    w = np.arange(-np.floor(nn / 2), np.ceil(nn / 2) - 1) * dw
+
+    w1 = __find_flank(w, spectrum, cutoff)
+    w2 = __find_flank(w[::-1], spectrum[::-1], cutoff)
+
+    bw = w2 - w1
+
+    if return_spectrum and not return_axis:
+        return bw, spectrum
+    if return_axis:
+        return bw, spectrum, w
+
+    return bw
+
+
+def __find_flank(x, f, c):
+    m = np.max(np.abs(f))
+    f = np.abs(f) / m
+    i = np.argwhere(f > c)[0]
+
+    return x[i]

+ 31 - 0
libs/lf-scanner/pypulseq/calc_rf_center.py

@@ -0,0 +1,31 @@
+from types import SimpleNamespace
+from typing import Tuple
+
+import numpy as np
+
+
+def calc_rf_center(rf: SimpleNamespace) -> Tuple[float, float]:
+    """
+    Calculate the time point of the effective rotation calculated as the peak of the radio-frequency amplitude for the
+    shaped pulses and the center of the pulse for the block pulses. Zero padding in the radio-frequency pulse is
+    considered as a part of the shape. Delay field of the radio-frequency object is not taken into account.
+
+    Parameters
+    ----------
+    rf : SimpleNamespace
+        Radio-frequency pulse event.
+
+    Returns
+    -------
+    time_center : float
+        Time point of the center of the radio-frequency pulse.
+    id_center : float
+        Corresponding position of `time_center` in the radio-frequency pulse's envelope.
+    """
+    # Detect the excitation peak; if i is a plateau take its center
+    rf_max = np.max(np.abs(rf.signal))
+    i_peak = np.where(np.abs(rf.signal) >= rf_max * 0.99999)[0]
+    time_center = (rf.t[i_peak[0]] + rf.t[i_peak[-1]]) / 2
+    id_center = i_peak[int(np.round((len(i_peak) - 1) / 2))]
+
+    return time_center, id_center

+ 119 - 0
libs/lf-scanner/pypulseq/check_timing.py

@@ -0,0 +1,119 @@
+from types import SimpleNamespace
+from typing import Tuple
+
+import numpy as np
+
+from LF_scanner.pypulseq import eps
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.opts import Opts
+
+
+def check_timing(system: Opts, *events: SimpleNamespace) -> Tuple[bool, str, float]:
+    """
+    Checks if timings of `events` are aligned with the corresponding raster time.
+
+    Parameters
+    ----------
+    system : Opts
+        System limits object.
+    events : SimpleNamespace
+        Events.
+
+    Returns
+    -------
+    is_ok : bool
+        Boolean flag indicating if timing of events `events` are aligned with gradient raster time
+        `system.grad_raster_time`.
+    text_err : str
+        Error string, if timings are not aligned.
+    total_duration : float
+        Total duration of events.
+
+    Raises
+    ------
+    ValueError
+        If incorrect data type is encountered in `events`.
+    """
+    if len(events) == 0:
+        text_err = "Empty or damaged block detected"
+        is_ok = False
+        total_duration = 0.0
+        return is_ok, text_err, total_duration
+
+    total_duration = calc_duration(*events)
+    is_ok = __div_check(total_duration, system.block_duration_raster)
+    text_err = "" if is_ok else f"Total duration: {total_duration * 1e6} us"
+
+    for e in events:
+        if isinstance(e, (float, int)):  # Special handling for block_duration
+            continue
+        elif not isinstance(e, (dict, SimpleNamespace)):
+            raise ValueError(
+                "Wrong data type of variable arguments, list[SimpleNamespace] expected."
+            )
+        ok = True
+        if isinstance(e, list) and len(e) > 1:
+            # For now this is only the case for arrays of extensions, but we cannot actually check extensions anyway...
+            continue
+        if hasattr(e, "type") and (e.type == "adc" or e.type == "rf"):
+            raster = system.rf_raster_time
+        else:
+            raster = system.grad_raster_time
+
+        if hasattr(e, "delay"):
+            if e.delay < -eps:
+                ok = False
+            if not __div_check(e.delay, raster):
+                ok = False
+
+        if hasattr(e, "duration"):
+            if not __div_check(e.duration, raster):
+                ok = False
+
+        if hasattr(e, "dwell"):
+            if (
+                e.dwell < system.adc_raster_time
+                or np.abs(
+                    np.round(e.dwell / system.adc_raster_time) * system.adc_raster_time
+                    - e.dwell
+                )
+                > 1e-10
+            ):
+                ok = False
+
+        if hasattr(e, "type") and e.type == "trap":
+            if (
+                not __div_check(e.rise_time, system.grad_raster_time)
+                or not __div_check(e.flat_time, system.grad_raster_time)
+                or not __div_check(e.fall_time, system.grad_raster_time)
+            ):
+                ok = False
+
+        if not ok:
+            is_ok = False
+
+            text_err = "["
+            if hasattr(e, "type"):
+                text_err += f"type: {e.type} "
+            if hasattr(e, "delay"):
+                text_err += f"delay: {e.delay * 1e6} us "
+            if hasattr(e, "duration"):
+                text_err += f"duration: {e.duration * 1e6} us"
+            if hasattr(e, "dwell"):
+                text_err += f"dwell: {e.dwell * 1e9} ns"
+            if hasattr(e, "type") and e.type == "trap":
+                text_err += (
+                    f"rise time: {e.rise_time * 1e6} flat time: {e.flat_time * 1e6} "
+                    f"fall time: {e.fall_time * 1e6} us"
+                )
+            text_err += "]"
+
+    return is_ok, text_err, total_duration
+
+
+def __div_check(a: float, b: float) -> bool:
+    """
+    Checks whether `a` can be divided by `b` to an accuracy of 1e-9.
+    """
+    c = a / b
+    return abs(c - np.round(c)) < 1e-9

+ 76 - 0
libs/lf-scanner/pypulseq/compress_shape.py

@@ -0,0 +1,76 @@
+from types import SimpleNamespace
+
+import numpy as np
+
+
+def compress_shape(
+    decompressed_shape: np.ndarray, force_compression: bool = False
+) -> SimpleNamespace:
+    """
+    Compress a gradient or pulse shape waveform using a run-length compression scheme on the derivative. This strategy
+    encodes constant and linear waveforms with very few samples. A structure is returned with the fields:
+    - num_samples - the number of samples in the uncompressed waveform
+    - data - containing the compressed waveform
+
+    See also `pypulseq.decompress_shape.py`.
+
+    Parameters
+    ----------
+    decompressed_shape : numpy.ndarray
+        Decompressed shape.
+    force_compression: bool, default=False
+        Boolean flag to indicate if compression is forced.
+
+    Returns
+    -------
+    compressed_shape : SimpleNamespace
+        A `SimpleNamespace` object containing the number of samples and the compressed data.
+    """
+    if np.any(~np.isfinite(decompressed_shape)):
+        raise ValueError("compress_shape() received infinite samples.")
+
+    if (
+        not force_compression and len(decompressed_shape) <= 4
+    ):  # Avoid compressing very short shapes
+        compressed_shape = SimpleNamespace()
+        compressed_shape.num_samples = len(decompressed_shape)
+        compressed_shape.data = decompressed_shape
+        return compressed_shape
+
+    # Single precision floating point has ~7.25 decimal places
+    quant_factor = 1e-7
+    decompressed_shape_scaled = decompressed_shape / quant_factor
+    datq = np.round(
+        np.insert(np.diff(decompressed_shape_scaled), 0, decompressed_shape_scaled[0])
+    )
+    qerr = decompressed_shape_scaled - np.cumsum(datq)
+    qcor = np.insert(np.diff(np.round(qerr)), 0, 0)
+    datd = datq + qcor
+
+    mask_changes = np.insert(np.asarray(np.diff(datd) != 0, dtype=np.int32), 0, 1)
+    # Elements without repetitions
+    vals = datd[mask_changes.nonzero()[0]] * quant_factor
+
+    # Indices of changes
+    k = np.append(mask_changes, 1).nonzero()[0]
+    # Number of repetitions
+    n = np.diff(k)
+
+    n_extra = (n - 2).astype(np.float32)  # Cast as float for nan assignment to work
+    vals2 = np.copy(vals)
+    vals2[n_extra < 0] = np.nan
+    n_extra[n_extra < 0] = np.nan
+    v = np.stack((vals, vals2, n_extra))
+    v = v.T[np.isfinite(v).T]  # Use transposes to match Matlab's Fortran indexing order
+    v[abs(v) < 1e-10] = 0
+
+    compressed_shape = SimpleNamespace()
+    compressed_shape.num_samples = len(decompressed_shape)
+
+    # Decide whether compression makes sense, otherwise store the original
+    if force_compression or compressed_shape.num_samples > len(v):
+        compressed_shape.data = v
+    else:
+        compressed_shape.data = decompressed_shape
+
+    return compressed_shape

+ 91 - 0
libs/lf-scanner/pypulseq/convert.py

@@ -0,0 +1,91 @@
+from typing import Iterable, Union
+
+import numpy as np
+
+
+def convert(
+    from_value: Union[float, Iterable],
+    from_unit: str,
+    gamma: float = 42.576e6,
+    to_unit: str = str(),
+) -> Union[float, Iterable]:
+    """
+    Converts gradient amplitude or slew rate from unit `from_unit` to unit `to_unit` with gyromagnetic ratio `gamma`.
+
+    Parameters
+    ----------
+    from_value : float
+        Gradient amplitude or slew rate to convert from.
+    from_unit : str
+        Unit of gradient amplitude or slew rate to convert from.
+    to_unit : str, default=''
+        Unit of gradient amplitude or slew rate to convert to.
+    gamma : float, default=42.576e6
+        Gyromagnetic ratio. Default is 42.576e6, for Hydrogen.
+
+    Returns
+    -------
+    out : float
+        Converted gradient amplitude or slew rate.
+
+    Raises
+    ------
+    ValueError
+        If an invalid `from_unit` is passed. Must be one of 'Hz/m', 'mT/m', or 'rad/ms/mm'.
+        If an invalid `to_unit` is passed. Must be one of 'Hz/m/s', 'mT/m/ms', 'T/m/s', 'rad/ms/mm/ms'.
+    """
+    valid_grad_units = ["Hz/m", "mT/m", "rad/ms/mm"]
+    valid_slew_units = ["Hz/m/s", "mT/m/ms", "T/m/s", "rad/ms/mm/ms"]
+    valid_units = valid_grad_units + valid_slew_units
+
+    if from_unit not in valid_units:
+        raise ValueError(
+            "Invalid from_unit. Must be one of 'Hz/m', 'mT/m', or 'rad/ms/mm' for gradients;"
+            "or must be one of 'Hz/m/s', 'mT/m/ms', 'T/m/s', 'rad/ms/mm/ms' for slew rate."
+        )
+
+    if to_unit != "" and to_unit not in valid_units:
+        raise ValueError(
+            "Invalid to_unit. Must be one of 'Hz/m/s', 'mT/m/ms', 'T/m/s', 'rad/ms/mm/ms' for gradients;"
+            "or must be one of 'Hz/m/s', 'mT/m/ms', 'T/m/s', 'rad/ms/mm/ms' for slew rate.."
+        )
+
+    if to_unit == "":
+        if from_unit in valid_grad_units:
+            to_unit = valid_grad_units[0]
+        elif from_unit in valid_slew_units:
+            to_unit = valid_slew_units[0]
+
+    # Convert to standard units
+    # Grad units
+    if from_unit == "Hz/m":
+        standard = from_value
+    elif from_unit == "mT/m":
+        standard = from_value * 1e-3 * gamma
+    elif from_unit == "rad/ms/mm":
+        standard = from_value * 1e6 / (2 * np.pi)
+    # Slew units
+    elif from_unit == "Hz/m/s":
+        standard = from_value
+    elif from_unit == "mT/m/ms" or from_unit == "T/m/s":
+        standard = from_value * gamma
+    elif from_unit == "rad/ms/mm/ms":
+        standard = from_value * 1e9 / (2 * np.pi)
+
+    # Convert from standard units
+    # Grad units
+    if to_unit == "Hz/m":
+        out = standard
+    elif to_unit == "mT/m":
+        out = 1e3 * standard / gamma
+    elif to_unit == "rad/ms/mm":
+        out = standard * 2 * np.pi * 1e-6
+    # Slew units
+    elif to_unit == "Hz/m/s":
+        out = standard
+    elif to_unit == "mT/m/ms" or to_unit == "T/m/s":
+        out = standard / gamma
+    elif to_unit == "rad/ms/mm/ms":
+        out = standard * 2 * np.pi * 1e-9
+
+    return out

+ 74 - 0
libs/lf-scanner/pypulseq/decompress_shape.py

@@ -0,0 +1,74 @@
+from types import SimpleNamespace
+
+import numpy as np
+
+
+def decompress_shape(
+    compressed_shape: SimpleNamespace, force_decompression: bool = False
+) -> np.ndarray:
+    """
+    Decompress a gradient or pulse shape compressed with a run-length compression scheme on the derivative. The given
+    shape is structure with the following fields:
+    - num_samples - the number of samples in the uncompressed waveform
+    - data - containing the compressed waveform
+
+    See also `compress_shape.py`.
+
+    Parameters
+    ----------
+    compressed_shape : SimpleNamespace
+        Run-length encoded shape.
+    force_decompression : bool, default=False
+
+    Returns
+    -------
+    decompressed_shape : numpy.ndarray
+        Decompressed shape.
+    """
+    data_pack = compressed_shape.data
+    data_pack_len = len(data_pack)
+    num_samples = int(compressed_shape.num_samples)
+
+    if not force_decompression and num_samples == data_pack_len:
+        # Uncompressed shape
+        decompressed_shape = data_pack
+        return decompressed_shape
+
+    decompressed_shape = np.zeros(num_samples)  # Pre-allocate result matrix
+
+    # Decompression starts here
+    data_pack_diff = data_pack[1:] - data_pack[:-1]
+
+    # When data_pack_diff == 0 the subsequent samples are equal ==> marker for repeats (run-length encoding)
+    data_pack_markers = np.where(data_pack_diff == 0.0)[0]
+
+    count_pack = 0  # Points to current compressed sample
+    count_unpack = 0  # Points to current uncompressed sample
+
+    for i in range(len(data_pack_markers)):
+        # This index may have "false positives", e.g. if the value 3 repeats 3 times, then we will have 3 3 3
+        next_pack = data_pack_markers[i]
+        current_unpack_samples = next_pack - count_pack
+        if current_unpack_samples < 0:  # Rejects false positives
+            continue
+        elif current_unpack_samples > 0:  # We have an unpacked block to copy
+            decompressed_shape[
+                count_unpack : count_unpack + current_unpack_samples
+            ] = data_pack[count_pack:next_pack]
+            count_pack += current_unpack_samples
+            count_unpack += current_unpack_samples
+
+        # Packed/repeated section
+        rep = int(data_pack[count_pack + 2] + 2)
+        decompressed_shape[count_unpack : (count_unpack + rep)] = data_pack[count_pack]
+        count_pack += 3
+        count_unpack += rep
+
+    # Samples left?
+    if count_pack <= data_pack_len - 1:
+        assert data_pack_len - count_pack == num_samples - count_unpack
+        # Copy the rest of the shape, it is unpacked
+        decompressed_shape[count_unpack:] = data_pack[count_pack:]
+
+    decompressed_shape = np.cumsum(decompressed_shape)
+    return decompressed_shape

+ 316 - 0
libs/lf-scanner/pypulseq/event_lib.py

@@ -0,0 +1,316 @@
+from types import SimpleNamespace
+from typing import Tuple, Union
+
+try:
+    from typing import Self
+except ImportError:
+    from typing import TypeVar
+
+    Self = TypeVar('Self', bound='EventLibrary')
+
+import math
+import numpy as np
+
+
+class EventLibrary:
+    """
+    Defines an event library ot maintain a list of events. Provides methods to insert new data and find existing data.
+
+    Sequence Properties:
+    - data - A struct array with field 'array' to store data of varying lengths, remaining compatible with codegen.
+    - type - Type to distinguish events in the same class (e.g. trapezoids and arbitrary gradients)
+
+    Sequence Methods:
+    - find - Find an event in the library
+    - insert - Add a new event to the library
+
+    See also `Sequence.py`.
+
+    Attributes
+    ----------
+    data : dict{str: numpy.array}
+        Key-value pairs of event keys and corresponding data.
+    type : dict{str, str}
+        Key-value pairs of event keys and corresponding event types.
+    keymap : dict{str, int}
+        Key-value pairs of data values and corresponding event keys.
+    """
+
+    def __init__(self, numpy_data=False):
+        self.data = dict()
+        self.type = dict()
+        self.keymap = dict()
+        self.next_free_ID = 1
+        self.numpy_data = numpy_data
+
+    def __str__(self) -> str:
+        s = "EventLibrary:"
+        s += "\ndata: " + str(len(self.data))
+        s += "\ntype: " + str(len(self.type))
+        return s
+
+    def find(self, new_data: np.ndarray) -> Tuple[int, bool]:
+        """
+        Finds data `new_data` in event library.
+
+        Parameters
+        ----------
+        new_data : numpy.ndarray
+            Data to be found in event library.
+
+        Returns
+        -------
+        key_id : int
+            Key of `new_data` in event library, if found.
+        found : bool
+            If `new_data` was found in the event library or not.
+        """
+        if self.numpy_data:
+            new_data = np.asarray(new_data)
+            key = new_data.tobytes()
+        else:
+            key = tuple(new_data)
+
+        if key in self.keymap:
+            key_id = self.keymap[key]
+            found = True
+        else:
+            key_id = self.next_free_ID
+            found = False
+
+        return key_id, found
+
+    def find_or_insert(
+            self, new_data: np.ndarray, data_type: str = str()
+    ) -> Tuple[int, bool]:
+        """
+        Lookup a data structure in the given library and return the index of the data in the library. If the data does
+        not exist in the library it is inserted right away. The data is a 1xN array with event-specific data.
+
+        See also  insert `pypulseq.Sequence.sequence.Sequence.add_block()`.
+
+        Parameters
+        ----------
+        new_data : numpy.ndarray
+            Data to be found (or added, if not found) in event library.
+        data_type : str, default=str()
+            Type of data.
+
+        Returns
+        -------
+        key_id : int
+            Key of `new_data` in event library, if found.
+        found : bool
+            If `new_data` was found in the event library or not.
+        """
+
+        if self.numpy_data:
+            new_data = np.asarray(new_data)
+            new_data.flags.writeable = False
+            key = new_data.tobytes()
+        else:
+            key = tuple(new_data)
+
+        if key in self.keymap:
+            key_id = self.keymap[key]
+            found = True
+        else:
+            key_id = self.next_free_ID
+            found = False
+
+            # Insert
+            self.data[key_id] = new_data
+
+            if data_type != str():
+                self.type[key_id] = data_type
+
+            self.keymap[key] = key_id
+            self.next_free_ID = key_id + 1  # Update next_free_id
+
+        return key_id, found
+
+    def insert(self, key_id: int, new_data: np.ndarray, data_type: str = str()) -> int:
+        """
+        Add event to library.
+
+        See also `pypulseq.event_library.EventLibrary.find()`.
+
+        Parameters
+        ----------
+        key_id : int
+            Key of `new_data`.
+        new_data : numpy.ndarray
+            Data to be inserted into event library.
+        data_type : str, default=str()
+            Data type of `new_data`.
+
+        Returns
+        -------
+        key_id : int
+            Key ID of inserted event.
+        """
+        if isinstance(key_id, float):
+            key_id = int(key_id)
+
+        if key_id == 0:
+            key_id = self.next_free_ID
+
+        if self.numpy_data:
+            new_data = np.asarray(new_data)
+            new_data.flags.writeable = False
+            key = new_data.tobytes()
+        else:
+            key = tuple(new_data)
+
+        self.data[key_id] = new_data
+        if data_type != str():
+            self.type[key_id] = data_type
+
+        self.keymap[key] = key_id
+
+        if key_id >= self.next_free_ID:
+            self.next_free_ID = key_id + 1  # Update next_free_id
+
+        return key_id
+
+    def get(self, key_id: int) -> dict:
+        """
+
+        Parameters
+        ----------
+        key_id : int
+
+        Returns
+        -------
+        dict
+        """
+        return {
+            "key": key_id,
+            "data": self.data[key_id],
+            "type": self.type[key_id],
+        }
+
+    def out(self, key_id: int) -> SimpleNamespace:
+        """
+        Get element from library by key.
+
+        See also `pypulseq.event_library.EventLibrary.find()`.
+
+        Parameters
+        ----------
+        key_id : int
+
+        Returns
+        -------
+        out : SimpleNamespace
+        """
+        out = SimpleNamespace()
+        out.key = key_id
+        out.data = self.data[key_id]
+        out.type = self.type[key_id]
+
+        return out
+
+    def update(
+            self,
+            key_id: int,
+            old_data: np.ndarray,
+            new_data: np.ndarray,
+            data_type: str = str(),
+    ):
+        """
+        Parameters
+        ----------
+        key_id : int
+        old_data : numpy.ndarray (Ignored!)
+        new_data : numpy.ndarray
+        data_type : str, default=str()
+        """
+        if key_id in self.data:
+            if self.data[key_id] in self.keymap:
+                del self.keymap[self.data[key_id]]
+
+        self.insert(key_id, new_data, data_type)
+
+    def update_data(
+            self,
+            key_id: int,
+            old_data: np.ndarray,
+            new_data: np.ndarray,
+            data_type: str = str(),
+    ):
+        """
+        Parameters
+        ----------
+        key_id : int
+        old_data : np.ndarray (Ignored!)
+        new_data : np.ndarray
+        data_type : str
+        """
+        self.update(key_id, old_data, new_data, data_type)
+
+    def remove_duplicates(self, digits: Union[int, Tuple[int]]) -> Tuple[Self, dict]:
+        """
+        Remove duplicate events from this event library by rounding the data
+        according to the significant `digits` specification, and then removing
+        duplicate events.
+        Returns a new event library, leaving the current one intact.
+
+        Parameters
+        ----------
+        digits : Union[int, List[int]]
+            For libraries with `numpy_data == True`:
+                A single number specifying the number of significant digits
+                after rounding.
+            Otherwise:
+                A tuple of numbers specifying the number of significant digits
+                after rounding for each entry in the event data tuple.
+
+        Returns
+        -------
+        new_library : EventLibrary
+            Event library with the duplicate events removed
+        mapping : dict
+            Dictionary containing a mapping of IDs in the old library to IDs
+            in the new library.
+        """
+
+        def round_data(data: Tuple[float], digits: Tuple[int]) -> Tuple[float]:
+            """
+            Round the data tuple to a specified number of significant digits,
+            specified by `digits`. Rounding behaviour is similar to the {.Ng}
+            format specifier if N > 0, and similar to {.0f} otherwise.
+            """
+            return tuple(round(d, dig - int(math.ceil(math.log10(abs(d) + 1e-12))) if dig > 0 else -dig) for d, dig in
+                         zip(data, digits))
+
+        def round_data_numpy(data: np.ndarray, digits: int) -> np.ndarray:
+            """
+            Round the data array to a specified number of significant digits,
+            specified by `digits`. Rounding behaviour is similar to the {.Ng}
+            format specifier if N > 0, and similar to {.0f} otherwise.
+            """
+            mags = 10 ** (digits - (np.ceil(np.log10(abs(data) + 1e-12))) if digits > 0 else -digits)
+            result = np.round(data * mags) / mags
+            result.flags.writeable = False
+            return result
+
+        # Round library data based on `digits` specification
+        if self.numpy_data:
+            rounded_data = {x: round_data_numpy(self.data[x], digits) for x in self.data}
+        else:
+            rounded_data = {x: round_data(self.data[x], digits) for x in self.data}
+
+        # Initialize filtered library
+        new_library = EventLibrary(numpy_data=self.numpy_data)
+
+        # Initialize ID mapping. Always include 0:0 to allow the mapping dict
+        # to be used for mapping block_events (which can contain 0, i.e. no
+        # event)
+        mapping = {0: 0}
+
+        # Recreate library using rounded values
+        for k, v in sorted(rounded_data.items()):
+            mapping[k], _ = new_library.find_or_insert(v, self.type[k] if k in self.type else str())
+
+        return new_library, mapping

+ 66 - 0
libs/lf-scanner/pypulseq/make_adc.py

@@ -0,0 +1,66 @@
+from types import SimpleNamespace
+
+from LF_scanner.pypulseq.opts import Opts
+
+
+def make_adc(
+    num_samples: int,
+    delay: float = 0,
+    duration: float = 0,
+    dwell: float = 0,
+    freq_offset: float = 0,
+    phase_offset: float = 0,
+    system: Opts = Opts(),
+) -> SimpleNamespace:
+    """
+    Create an ADC readout event.
+
+    Parameters
+    ----------
+    num_samples: int
+        Number of readout samples.
+    system : Opts, default=Opts()
+        System limits. Default is a system limits object initialised to default values.
+    dwell : float, default=0
+        ADC dead time in seconds (s) after sampling.
+    duration : float, default=0
+        Duration in seconds (s) of ADC readout event with `num_samples` number of samples.
+    delay : float, default=0
+        Delay in seconds (s) of ADC readout event.
+    freq_offset : float, default=0
+        Frequency offset of ADC readout event.
+    phase_offset : float, default=0
+        Phase offset of ADC readout event.
+
+    Returns
+    -------
+    adc : SimpleNamespace
+        ADC readout event.
+
+    Raises
+    ------
+    ValueError
+        If neither `dwell` nor `duration` are defined.
+    """
+    adc = SimpleNamespace()
+    adc.type = "adc"
+    adc.num_samples = num_samples
+    adc.dwell = dwell
+    adc.delay = delay
+    adc.freq_offset = freq_offset
+    adc.phase_offset = phase_offset
+    adc.dead_time = system.adc_dead_time
+
+    if (dwell == 0 and duration == 0) or (dwell > 0 and duration > 0):
+        raise ValueError("Either dwell or duration must be defined")
+
+    if duration > 0:
+        adc.dwell = duration / num_samples
+
+    if dwell > 0:
+        adc.duration = dwell * num_samples
+
+    if adc.dead_time > adc.delay:
+        adc.delay = adc.dead_time
+
+    return adc

+ 262 - 0
libs/lf-scanner/pypulseq/make_adiabatic_pulse.py

@@ -0,0 +1,262 @@
+from types import SimpleNamespace
+from typing import Tuple, Union
+
+import numpy as np
+from sigpy.mri.rf import hypsec, wurst
+
+from LF_scanner.pypulseq import eps
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.calc_rf_center import calc_rf_center
+from LF_scanner.pypulseq.make_delay import make_delay
+from LF_scanner.pypulseq.make_trapezoid import make_trapezoid
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_rf_uses
+
+
+def make_adiabatic_pulse(
+    pulse_type: str,
+    adiabaticity: int = 4,
+    bandwidth: int = 40000,
+    beta: int = 800,
+    delay: float = 0,
+    duration: float = 10e-3,
+    dwell: float = 0,
+    freq_offset: float = 0,
+    max_grad: float = 0,
+    max_slew: float = 0,
+    n_fac: int = 40,
+    mu: float = 4.9,
+    phase_offset: float = 0,
+    return_gz: bool = False,
+    return_delay: bool = False,
+    slice_thickness: float = 0,
+    system=Opts(),
+    use: str = str(),
+) -> Union[
+    SimpleNamespace,
+    Tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, SimpleNamespace],
+    Tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace],
+]:
+    """
+    Make an adiabatic inversion pulse.
+
+    Note: some parameters only affect certain pulse types and are ignored for other; e.g. bandwidth is ignored if
+    type='hypsec'.
+
+    hypsec(n=512, beta=800, mu=4.9, dur=0.012)
+        Design a hyperbolic secant adiabatic pulse. `mu` * `beta` becomes the amplitude of the frequency sweep.
+
+        Args:
+            - n (int): number of samples (should be a multiple of 4).
+            - beta (float): AM waveform parameter.
+            - mu (float): a constant, determines amplitude of frequency sweep.
+            - dur (float): pulse time (s).
+
+        Returns:
+            2-element tuple containing
+            - **a** (*array*): AM waveform.
+            - **om** (*array*): FM waveform (radians/s).
+
+        References:
+            Baum, J., Tycko, R. and Pines, A. (1985). 'Broadband and adiabatic
+            inversion of a two-level system by phase-modulated pulses'.
+            Phys. Rev. A., 32:3435-3447.
+
+    wurst(n=512, n_fac=40, bw=40000.0, dur=0.002)
+        Design a WURST (wideband, uniform rate, smooth truncation) adiabatic inversion pulse
+
+        Args:
+            - n (int): number of samples (should be a multiple of 4).
+            - n_fac (int): power to exponentiate to within AM term. ~20 or greater is typical.
+            - bw (float): pulse bandwidth.
+            - dur (float): pulse time (s).
+
+        Returns:
+            2-element tuple containing
+            - **a** (*array*): AM waveform.
+            - **om** (*array*): FM waveform (radians/s).
+
+        References:
+            Kupce, E. and Freeman, R. (1995). 'Stretched Adiabatic Pulses for
+            Broadband Spin Inversion'.
+            J. Magn. Reson. Ser. A., 117:246-256.
+
+    Parameters
+    ----------
+    pulse_type : str
+        One of 'hypsec' or 'wurst' pulse types.
+    adiabaticity : int, default=4
+    bandwidth : int, default=40000
+        Pulse bandwidth.
+    beta : int, default=800
+        AM waveform parameter.
+    delay : float, default=0
+        Delay in seconds (s).
+    duration : float, default=10e-3
+        Pulse time (s).
+    dwell : float, default=0
+    freq_offset : float, default=0
+    max_grad : float, default=0
+        Maximum gradient strength.
+    max_slew : float, default=0
+        Maximum slew rate.
+    mu : float, default=4.9
+        Constant determining amplitude of frequency sweep.
+    n_fac : int, default=40
+        Power to exponentiate to within AM term. ~20 or greater is typical.
+    phase_offset : float, default=0
+        Phase offset.
+    return_delay : bool, default=False
+        Boolean flag to indicate if the delay has to be returned.
+    return_gz : bool, default=False
+        Boolean flag to indicate if the slice-selective gradient has to be returned.
+    slice_thickness : float, default=0
+    system : Opts, default=Opts()
+        System limits.
+    use : str
+        Whether it is a 'refocusing' pulse (for k-space calculation).
+
+    Returns
+    -------
+    rf : SimpleNamespace
+        Adiabatic RF pulse event.
+    gz : SimpleNamespace, optional
+        Slice-selective trapezoid event.
+    gzr : SimpleNamespace, optional
+        Slice-select rephasing trapezoid event.
+    delay : SimpleNamespace, optional
+        Delay event.
+
+    Raises
+    ------
+    ValueError
+        If invalid pulse type is encountered.
+        If invalid pulse use is encountered.
+        If slice thickness is not provided but slice-selective trapezoid event is expected.
+    """
+    valid_pulse_types = ["hypsec", "wurst"]
+    if pulse_type != "" and pulse_type not in valid_pulse_types:
+        raise ValueError(
+            f"Invalid type parameter. Must be one of {valid_pulse_types}.Passed: {pulse_type}"
+        )
+    valid_pulse_uses = get_supported_rf_uses()
+    if use != "" and use not in valid_pulse_uses:
+        raise ValueError(
+            f"Invalid use parameter. Must be one of {valid_pulse_uses}. Passed: {use}"
+        )
+
+    if dwell == 0:
+        dwell = system.rf_raster_time
+
+    n_raw = np.round(duration / dwell + eps)
+    # Number of points must be divisible by 4 - requirement of sigpy.mri
+    N = np.floor(n_raw / 4) * 4
+
+    if pulse_type == "hypsec":
+        am, fm = hypsec(n=N, beta=beta, mu=mu, dur=duration)
+    elif pulse_type == "wurst":
+        am, fm = wurst(n=N, n_fac=n_fac, bw=bandwidth, dur=duration)
+    else:
+        raise ValueError("Unsupported adiabatic pulse type.")
+
+    pm = np.cumsum(fm) * dwell
+
+    ifm = np.argmin(np.abs(fm))
+    dfm = np.abs(fm)[ifm]
+
+    # Find rate of change of frequency at the center of the pulse
+    if dfm == 0:
+        pm0 = pm[ifm]
+        am0 = am[ifm]
+        roc_fm0 = np.abs(fm[ifm + 1] - fm[ifm - 1]) / 2 / dwell
+    else:  # We need to bracket the zero-crossing
+        if fm[ifm] * fm[ifm + 1] < 0:
+            b = 1
+        else:
+            b = -1
+
+        pm0 = (pm[ifm] * fm[ifm + b] - pm[ifm + b] * fm[ifm]) / (fm[ifm + b] - fm[ifm])
+        am0 = (am[ifm] * fm[ifm + b] - am[ifm + b] * fm[ifm]) / (fm[ifm + b] - fm[ifm])
+        roc_fm0 = np.abs(fm[ifm] - fm[ifm + b]) / dwell
+
+    pm -= pm0
+    a = (roc_fm0 * adiabaticity) ** 0.5 / 2 / np.pi / am0
+
+    signal = a * am * np.exp(1j * pm)
+
+    if N != n_raw:
+        n_pad = n_raw - N
+        signal = [
+            np.zeros(1, n_pad - np.floor(n_pad / 2)),
+            signal,
+            np.zeros(1, np.floor(n_pad / 2)),
+        ]
+        N = n_raw
+
+    t = (np.arange(1, N + 1) - 0.5) * dwell
+
+    rf = SimpleNamespace()
+    rf.type = "rf"
+    rf.signal = signal
+    rf.t = t
+    rf.shape_dur = N * dwell
+    rf.freq_offset = freq_offset
+    rf.phase_offset = phase_offset
+    rf.dead_time = system.rf_dead_time
+    rf.ringdown_time = system.rf_ringdown_time
+    rf.delay = delay
+    if use != "":
+        rf.use = use
+    else:
+        rf.use = "inversion"
+    if rf.dead_time > rf.delay:
+        rf.delay = rf.dead_time
+
+    if return_gz:
+        if slice_thickness <= 0:
+            raise ValueError("Slice thickness must be provided")
+
+        if max_grad > 0:
+            system.max_grad = max_grad
+
+        if max_slew > 0:
+            system.max_slew = max_slew
+
+        if pulse_type == "hypsec":
+            bandwidth = mu * beta / np.pi
+        elif pulse_type == "wurst":
+            bandwidth = bandwidth
+        else:
+            raise ValueError("Unsupported adiabatic pulse type.")
+
+        center_pos, _ = calc_rf_center(rf)
+
+        amplitude = bandwidth / slice_thickness
+        area = amplitude * duration
+        gz = make_trapezoid(
+            channel="z", system=system, flat_time=duration, flat_area=area
+        )
+        gzr = make_trapezoid(
+            channel="z",
+            system=system,
+            area=-area * (1 - center_pos) - 0.5 * (gz.area - area),
+        )
+
+        if rf.delay > gz.rise_time:  # Round-up to gradient raster
+            gz.delay = (
+                np.ceil((rf.delay - gz.rise_time) / system.grad_raster_time)
+                * system.grad_raster_time
+            )
+
+        if rf.delay < (gz.rise_time + gz.delay):
+            rf.delay = gz.rise_time + gz.delay
+
+    if rf.ringdown_time > 0 and return_delay:
+        delay = make_delay(calc_duration(rf) + rf.ringdown_time)
+
+    if return_gz and return_delay:
+        return rf, gz, gzr, delay
+    elif return_gz:
+        return rf, gz, gzr
+    else:
+        return rf

+ 77 - 0
libs/lf-scanner/pypulseq/make_arbitrary_grad.py

@@ -0,0 +1,77 @@
+from types import SimpleNamespace
+
+import numpy as np
+
+from LF_scanner.pypulseq.opts import Opts
+
+
+def make_arbitrary_grad(
+    channel: str,
+    waveform: np.ndarray,
+    delay: float = 0,
+    max_grad: float = 0,
+    max_slew: float = 0,
+    system: Opts = Opts(),
+) -> SimpleNamespace:
+    """
+    Creates a gradient event with arbitrary waveform.
+
+    See also `pypulseq.Sequence.sequence.Sequence.add_block()`.
+
+    Parameters
+    ----------
+    channel : str
+        Orientation of gradient event of arbitrary shape. Must be one of `x`, `y` or `z`.
+    waveform : numpy.ndarray
+        Arbitrary waveform.
+    system : Opts, default=Opts()
+        System limits.
+    max_grad : float, default=0
+        Maximum gradient strength.
+    max_slew : float, default=0
+        Maximum slew rate.
+    delay : float, default=0
+        Delay in seconds (s).
+
+    Returns
+    -------
+    grad : SimpleNamespace
+        Gradient event with arbitrary waveform.
+
+    Raises
+    ------
+    ValueError
+        If invalid `channel` is passed. Must be one of x, y or z.
+        If slew rate is violated.
+        If gradient amplitude is violated.
+    """
+    if channel not in ["x", "y", "z"]:
+        raise ValueError(
+            f"Invalid channel. Must be one of x, y or z. Passed: {channel}"
+        )
+
+    if max_grad <= 0:
+        max_grad = system.max_grad
+
+    if max_slew <= 0:
+        max_slew = system.max_slew
+
+    g = waveform
+    slew = np.squeeze(np.subtract(g[1:], g[:-1]) / system.grad_raster_time)
+    if max(abs(slew)) >= max_slew:
+        raise ValueError(f"Slew rate violation {max(abs(slew)) / max_slew * 100}")
+    if max(abs(g)) >= max_grad:
+        raise ValueError(f"Gradient amplitude violation {max(abs(g)) / max_grad * 100}")
+
+    grad = SimpleNamespace()
+    grad.type = "grad"
+    grad.channel = channel
+    grad.waveform = g
+    grad.delay = delay
+    # True timing and aux shape data
+    grad.tt = (np.arange(1, len(g) + 1) - 0.5) * system.grad_raster_time
+    grad.shape_dur = len(g) * system.grad_raster_time
+    grad.first = (3 * g[0] - g[1]) * 0.5  # Extrapolate by 1/2 gradient raster
+    grad.last = (g[-1] * 3 - g[-2]) * 0.5  # Extrapolate by 1/2 gradient raster
+
+    return grad

+ 154 - 0
libs/lf-scanner/pypulseq/make_arbitrary_rf.py

@@ -0,0 +1,154 @@
+from types import SimpleNamespace
+from typing import Tuple, Union
+
+import numpy as np
+
+from LF_scanner.pypulseq import make_delay, calc_duration
+from LF_scanner.pypulseq.make_trapezoid import make_trapezoid
+from LF_scanner.pypulseq.make_delay import make_delay
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_rf_uses
+
+
+def make_arbitrary_rf(
+    signal: np.ndarray,
+    flip_angle: float,
+    bandwidth: float = 0,
+    delay: float = 0,
+    dwell: float = 0,
+    freq_offset: float = 0,
+    max_grad: float = 0,
+    max_slew: float = 0,
+    phase_offset: float = 0,
+    return_delay: bool = False,
+    return_gz: bool = False,
+    slice_thickness: float = 0,
+    system: Opts = Opts(),
+    time_bw_product: float = 0,
+    use: str = str(),
+) -> Union[SimpleNamespace, Tuple[SimpleNamespace, SimpleNamespace]]:
+    """
+    Create an RF pulse with the given pulse shape.
+
+    Parameters
+    ----------
+    signal : numpy.ndarray
+        Arbitrary waveform.
+    flip_angle : float
+        Flip angle in radians.
+    bandwidth : float, default=0
+        Bandwidth in Hertz (Hz).
+    delay : float, default=0
+        Delay in seconds (s) of accompanying slice select trapezoidal event.
+    freq_offset : float, default=0
+        Frequency offset in Hertz (Hz).
+    max_grad : float, default=system.max_grad
+        Maximum gradient strength of accompanying slice select trapezoidal event.
+    max_slew : float, default=system.max_slew
+        Maximum slew rate of accompanying slice select trapezoidal event.
+    phase_offset : float, default=0
+        Phase offset in Hertz (Hz).a
+    return_delay : bool, default=False
+        Boolean flag to indicate if delay has to be returned.
+    return_gz : bool, default=False
+        Boolean flag to indicate if slice-selective gradient has to be returned.
+    slice_thickness : float, default=0
+        Slice thickness of accompanying slice select trapezoidal event. The slice thickness determines the area of the
+        slice select event.
+    system : Opts, default=Opts()
+        System limits.
+    time_bw_product : float, default=4
+        Time-bandwidth product.
+    use : str, default=str()
+        Use of arbitrary radio-frequency pulse event. Must be one of 'excitation', 'refocusing' or 'inversion'.
+
+    Returns
+    -------
+    rf : SimpleNamespace
+        Radio-frequency pulse event with arbitrary pulse shape.
+    gz : SimpleNamespace, optional
+        Slice select trapezoidal gradient event accompanying the arbitrary radio-frequency pulse event.
+
+    Raises
+    ------
+    ValueError
+        If invalid `use` parameter is passed. Must be one of 'excitation', 'refocusing' or 'inversion'.
+        If `signal` with ndim > 1 is passed.
+        If `return_gz=True`, and `slice_thickness` and `bandwidth` are not passed.
+    """
+    valid_use_pulses = get_supported_rf_uses()
+    if use != "" and use not in valid_use_pulses:
+        raise ValueError(
+            f"Invalid use parameter. Must be one of 'excitation', 'refocusing' or 'inversion'. Passed: {use}"
+        )
+
+    if dwell == 0:
+        dwell = system.rf_raster_time
+
+    signal = np.squeeze(signal)
+    if signal.ndim > 1:
+        raise ValueError(f"signal should have ndim=1. Passed ndim={signal.ndim}")
+    signal = signal / np.abs(np.sum(signal * dwell)) * flip_angle / (2 * np.pi)
+
+    N = len(signal)
+    duration = N * dwell
+    t = (np.arange(1, N + 1) - 0.5) * dwell
+
+    rf = SimpleNamespace()
+    rf.type = "rf"
+    rf.signal = signal
+    rf.t = t
+    rf.shape_dur = duration
+    rf.freq_offset = freq_offset
+    rf.phase_offset = phase_offset
+    rf.dead_time = system.rf_dead_time
+    rf.ringdown_time = system.rf_ringdown_time
+    rf.delay = delay
+
+    if use != "":
+        rf.use = use
+
+    if rf.dead_time > rf.delay:
+        rf.delay = rf.dead_time
+
+    if return_gz:
+        if slice_thickness <= 0:
+            raise ValueError("Slice thickness must be provided.")
+        if bandwidth <= 0:
+            raise ValueError("Bandwidth of pulse must be provided.")
+
+        if max_grad > 0:
+            system.max_grad = max_grad
+        if max_slew > 0:
+            system.max_slew = max_slew
+
+        BW = bandwidth
+        if time_bw_product > 0:
+            BW = time_bw_product / duration
+
+        amplitude = BW / slice_thickness
+        area = amplitude * duration
+        gz = make_trapezoid(
+            channel="z", system=system, flat_time=duration, flat_area=area
+        )
+
+        if rf.delay > gz.rise_time:
+            # Round-up to gradient raster
+            gz.delay = (
+                np.ceil((rf.delay - gz.rise_time) / system.grad_raster_time)
+                * system.grad_raster_time
+            )
+
+        if rf.delay < (gz.rise_time + gz.delay):
+            rf.delay = gz.rise_time + gz.delay
+
+    if rf.ringdown_time > 0 and return_delay:
+        delay = make_delay(calc_duration(rf) + rf.ringdown_time)
+
+    if return_gz and return_delay:
+        return rf, gz, delay
+    elif return_gz:
+        return rf, gz
+    else:
+        return rf

+ 106 - 0
libs/lf-scanner/pypulseq/make_block_pulse.py

@@ -0,0 +1,106 @@
+from types import SimpleNamespace
+from typing import Tuple, Union
+
+import numpy as np
+
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.make_delay import make_delay
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_rf_uses
+
+
+def make_block_pulse(
+    flip_angle: float,
+    bandwidth: float = 0,
+    delay: float = 0,
+    duration: float = 4e-3,
+    freq_offset: float = 0,
+    phase_offset: float = 0,
+    return_delay: bool = False,
+    system: Opts = Opts(),
+    time_bw_product: float = 0,
+    use: str = str(),
+) -> Union[SimpleNamespace, Tuple[SimpleNamespace, SimpleNamespace]]:
+    """
+    Create a block pulse with optional slice selectiveness.
+
+    Parameters
+    ----------
+    flip_angle : float
+        Flip angle in radians.
+    bandwidth : float, default=0
+        Bandwidth in Hertz (hz).
+    delay : float, default=0
+        Delay in seconds (s) of accompanying slice select trapezoidal event.
+    duration : float, default=4e-3
+        Duration in seconds (s).
+    freq_offset : float, default=0
+        Frequency offset in Hertz (Hz).
+    phase_offset : float, default=0
+        Phase offset Hertz (Hz).
+    return_delay : bool, default=False
+        Boolean flag to indicate if the delay event has to be returned.
+    system : Opts, default=Opts()
+        System limits.
+    time_bw_product : float, default=0
+        Time-bandwidth product.
+    use : str, default=str()
+        Use of radio-frequency block pulse event. Must be one of 'excitation', 'refocusing' or 'inversion'.
+
+    Returns
+    -------
+    rf : SimpleNamespace
+        Radio-frequency block pulse event.
+    delay : SimpleNamespace, optional
+        Slice select trapezoidal gradient event accompanying the radio-frequency block pulse event.
+
+    Raises
+    ------
+    ValueError
+        If invalid `use` parameter is passed. Must be one of 'excitation', 'refocusing' or 'inversion'.
+        If neither `bandwidth` nor `duration` are passed.
+        If `return_gz=True`, and `slice_thickness` is not passed.
+    """
+    valid_use_pulses = get_supported_rf_uses()
+    if use != "" and use not in valid_use_pulses:
+        raise ValueError(
+            f"Invalid use parameter. Must be one of 'excitation', 'refocusing' or 'inversion'. Passed: {use}"
+        )
+
+    if duration == 0:
+        if time_bw_product > 0:
+            duration = time_bw_product / bandwidth
+        elif bandwidth > 0:
+            duration = 1 / (4 * bandwidth)
+        else:
+            raise ValueError("Either bandwidth or duration must be defined")
+
+    BW = 1 / (4 * duration)
+    N = np.round(duration / system.rf_raster_time)
+    t = np.array([0, N]) * system.rf_raster_time
+    signal = flip_angle / (2 * np.pi) / duration * np.ones_like(t)
+
+    rf = SimpleNamespace()
+    rf.type = "rf"
+    rf.signal = signal
+    rf.t = t
+    rf.shape_dur = t[-1]
+    rf.freq_offset = freq_offset
+    rf.phase_offset = phase_offset
+    rf.dead_time = system.rf_dead_time
+    rf.ringdown_time = system.rf_ringdown_time
+    rf.delay = delay
+
+    if use != "":
+        rf.use = use
+
+    if rf.dead_time > rf.delay:
+        rf.delay = rf.dead_time
+
+    if rf.ringdown_time > 0 and return_delay:
+        delay = make_delay(calc_duration(rf) + rf.ringdown_time)
+
+    if return_delay:
+        return rf, delay
+    else:
+        return rf

+ 30 - 0
libs/lf-scanner/pypulseq/make_delay.py

@@ -0,0 +1,30 @@
+import numpy as np
+from types import SimpleNamespace
+
+
+def make_delay(d: float) -> SimpleNamespace:
+    """
+    Creates a delay event.
+
+    Parameters
+    ----------
+    d : float
+        Delay time in seconds (s).
+
+    Returns
+    -------
+    delay : SimpleNamespace
+        Delay event.
+
+    Raises
+    ------
+    ValueError
+        If delay is invalid (not finite or < 0).
+    """
+
+    delay = SimpleNamespace()
+    if not np.isfinite(d) or d < 0:
+        raise ValueError("Delay {:.2f} ms is invalid".format(d * 1e3))
+    delay.type = "delay"
+    delay.delay = d
+    return delay

+ 47 - 0
libs/lf-scanner/pypulseq/make_digital_output_pulse.py

@@ -0,0 +1,47 @@
+from types import SimpleNamespace
+
+from LF_scanner.pypulseq.opts import Opts
+
+
+def make_digital_output_pulse(
+    channel: str, delay: float = 0, duration: float = 4e-3, system: Opts = Opts()
+) -> SimpleNamespace:
+    """
+    Create a digital output pulse event a.k.a. trigger. Creates an output trigger event on a given channel with optional
+    given delay and duration.
+
+    Parameters
+    ----------
+    channel : str
+        Must be one of 'osc0','osc1', or 'ext1'.
+    delay : float, default=0
+        Delay in seconds (s).
+    duration : float, default=4e-3
+        Duration of trigger event in seconds (s).
+    system : Opts, default=Opts()
+        System limits.
+
+    Returns
+    ------
+    trig : SimpleNamespace
+        Trigger event.
+
+    Raises
+    ------
+    ValueError
+        If `channel` is invalid. Must be one of 'osc0','osc1', or 'ext1'.
+    """
+    if channel not in ["osc0", "osc1", "ext1"]:
+        raise ValueError(
+            f"Channel {channel} is invalid. Must be one of 'osc0','osc1', or 'ext1'."
+        )
+
+    trig = SimpleNamespace()
+    trig.type = "output"
+    trig.channel = channel
+    trig.delay = delay
+    trig.duration = duration
+    if trig.duration <= system.grad_raster_time:
+        trig.duration = system.grad_raster_time
+
+    return trig

+ 143 - 0
libs/lf-scanner/pypulseq/make_extended_trapezoid.py

@@ -0,0 +1,143 @@
+from types import SimpleNamespace
+
+import numpy as np
+
+from LF_scanner.pypulseq import eps
+from LF_scanner.pypulseq.make_arbitrary_grad import make_arbitrary_grad
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.points_to_waveform import points_to_waveform
+
+
+def make_extended_trapezoid(
+    channel: str,
+    amplitudes: np.ndarray = np.zeros(1),
+    convert_to_arbitrary: bool = False,
+    max_grad: float = 0,
+    max_slew: float = 0,
+    skip_check: bool = False,
+    system: Opts = Opts(),
+    times: np.ndarray = np.zeros(1),
+) -> SimpleNamespace:
+    """
+    Create a gradient by specifying a set of points (amplitudes) at specified time points(times) at a given channel
+    with given system limits. Returns an arbitrary gradient object.
+
+    See also:
+    - `pypulseq.Sequence.sequence.Sequence.add_block()`
+    - `pypulseq.opts.Opts`
+    - `pypulseq.make_trapezoid.make_trapezoid()`
+
+    Parameters
+    ----------
+    channel : str
+        Orientation of extended trapezoidal gradient event. Must be one of 'x', 'y' or 'z'.
+    convert_to_arbitrary : bool, default=False
+        Boolean flag to indicate if the extended trapezoid gradient has to be converted into an arbitrary gradient.
+    amplitudes : numpy.ndarray, default=09
+        Values defined at `times` time indices.
+    max_grad : float, default=0
+        Maximum gradient strength.
+    max_slew : float, default=0
+        Maximum slew rate.
+    system : Opts, default=Opts()
+        System limits.
+    skip_check : bool, default=False
+        Boolean flag to indicate if amplitude check is to be skipped.
+    times : numpy.ndarray, default=np.zeros(1)
+        Time points at which `amplitudes` defines amplitude values.
+
+    Returns
+    -------
+    grad : SimpleNamespace
+        Extended trapezoid gradient event.
+
+    Raises
+    ------
+    ValueError
+        If invalid `channel` is passed. Must be one of 'x', 'y' or 'z'.
+        If all elements in `times` are zero.
+        If elements in `times` are not in ascending order or not distinct.
+        If all elements in `amplitudes` are zero.
+        If first amplitude of a gradient is non-ero and does not connect to a previous block.
+    """
+    if channel not in ["x", "y", "z"]:
+        raise ValueError(
+            f"Invalid channel. Must be one of 'x', 'y' or 'z'. Passed: {channel}"
+        )
+
+    times = np.asarray(times)
+    amplitudes = np.asarray(amplitudes)
+
+    if len(times) != len(amplitudes):
+        raise ValueError("Times and amplitudes must have the same length.")
+
+    if np.all(times == 0):
+        raise ValueError("At least one of the given times must be non-zero")
+
+    if np.any(np.diff(times) <= 0):
+        raise ValueError(
+            "Times must be in ascending order and all times must be distinct"
+        )
+
+    if (
+        np.abs(
+            np.round(times[-1] / system.grad_raster_time) * system.grad_raster_time
+            - times[-1]
+        )
+        > eps
+    ):
+        raise ValueError("The last time point must be on a gradient raster")
+
+    if skip_check is False and times[0] > 0 and amplitudes[0] != 0:
+        raise ValueError(
+            "If first amplitude of a gradient is non-zero, it must connect to previous block"
+        )
+
+    if max_grad <= 0:
+        max_grad = system.max_grad
+
+    if max_slew <= 0:
+        max_slew = system.max_slew
+
+    if convert_to_arbitrary:
+        # Represent the extended trapezoid on the regularly sampled time grid
+        waveform = points_to_waveform(
+            times=times, amplitudes=amplitudes, grad_raster_time=system.grad_raster_time
+        )
+        grad = make_arbitrary_grad(
+            channel=channel,
+            waveform=waveform,
+            system=system,
+            max_slew=max_slew,
+            max_grad=max_grad,
+            delay=times[0],
+        )
+    else:
+        #  Keep the original possibly irregular sampling
+        if np.any(
+            np.abs(
+                np.round(times / system.grad_raster_time) * system.grad_raster_time
+                - times
+            )
+            > eps
+        ):
+            raise ValueError(
+                'All time points must be on a gradient raster or "convert_to_arbitrary" option must be used.'
+            )
+
+        grad = SimpleNamespace()
+        grad.type = "grad"
+        grad.channel = channel
+        grad.waveform = amplitudes
+        grad.delay = (
+            np.round(times[0] / system.grad_raster_time) * system.grad_raster_time
+        )
+        grad.tt = times - grad.delay
+        grad.shape_dur = (
+            np.round(times[-1] / system.grad_raster_time) * system.grad_raster_time
+        )
+
+    grad.first = amplitudes[0]
+    grad.last = amplitudes[-1]
+
+    return grad

+ 133 - 0
libs/lf-scanner/pypulseq/make_extended_trapezoid_area.py

@@ -0,0 +1,133 @@
+import math
+from types import SimpleNamespace
+from typing import Tuple
+
+import numpy as np
+from scipy.optimize import minimize
+
+from LF_scanner.pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from LF_scanner.pypulseq.opts import Opts
+
+
+def make_extended_trapezoid_area(
+    area: float, channel: str, grad_end: float, grad_start: float, system: Opts
+) -> Tuple[SimpleNamespace, np.array, np.array]:
+    """
+    Makes the shortest possible extended trapezoid with a given area which starts and ends (optionally) as non-zero
+    gradient values.
+
+    Parameters
+    ----------
+    channel : str
+        Orientation of extended trapezoidal gradient event. Must be one of 'x', 'y' or 'z'.
+    grad_start : float
+        Starting non-zero gradient value.
+    grad_end : float
+        Ending non-zero gradient value.
+    area : float
+        Area of extended trapezoid.
+    system: Opts
+        System limits.
+
+    Returns
+    -------
+    grad : SimpleNamespace
+        Extended trapezoid event.
+    times : numpy.ndarray
+    amplitude : numpy.ndarray
+    """
+    SR = system.max_slew * 0.99
+
+    Tp = 0
+    obj1 = (
+        lambda x: (
+            area - __testGA(x, 0, SR, system.grad_raster_time, grad_start, grad_end)
+        )
+        ** 2
+    )
+    arr_res = [
+        minimize(fun=obj1, x0=-system.max_grad, method="Nelder-Mead"),
+        minimize(fun=obj1, x0=0, method="Nelder-Mead"),
+        minimize(fun=obj1, x0=system.max_grad, method="Nelder-Mead"),
+    ]
+    arr_res = np.array([(*res.x, res.fun) for res in arr_res])
+    Gp, obj1val = arr_res[:, 0], arr_res[:, 1]
+    i_min = np.argmin(obj1val)
+    Gp = Gp[i_min]
+    obj1val = obj1val[i_min]
+
+    if obj1val > 1e-3 or np.abs(Gp) > system.max_grad:  # Search did not converge
+        Gp = system.max_grad * np.sign(Gp)
+        obj2 = (
+            lambda x: (
+                area
+                - __testGA(Gp, x, SR, system.grad_raster_time, grad_start, grad_end)
+            )
+            ** 2
+        )
+        res2 = minimize(fun=obj2, x0=0, method="Nelder-Mead")
+        T, obj2val = *res2.x, res2.fun
+        assert obj2val < 1e-2
+
+        Tp = np.ceil(T / system.grad_raster_time) * system.grad_raster_time
+
+        # Fix the ramps
+        Tru = (
+            np.ceil(np.abs(Gp - grad_start) / SR / system.grad_raster_time)
+            * system.grad_raster_time
+        )
+        Trd = (
+            np.ceil(np.abs(Gp - grad_end) / SR / system.grad_raster_time)
+            * system.grad_raster_time
+        )
+        obj3 = lambda x: (area - __testGA1(x, Tru, Tp, Trd, grad_start, grad_end)) ** 2
+
+        res = minimize(fun=obj3, x0=Gp, method="Nelder-Mead")
+        Gp, obj3val = *res.x, res.fun
+        assert obj3val < 1e-3  # Did the final search converge?
+
+    assert Tp >= 0
+
+    if Tp > 0:
+        times = np.cumsum([0, Tru, Tp, Trd])
+        amplitudes = [grad_start, Gp, Gp, grad_end]
+    else:
+        Tru = (
+            np.ceil(np.abs(Gp - grad_start) / SR / system.grad_raster_time)
+            * system.grad_raster_time
+        )
+        Trd = (
+            np.ceil(np.abs(Gp - grad_end) / SR / system.grad_raster_time)
+            * system.grad_raster_time
+        )
+
+        if Trd > 0:
+            if Tru > 0:
+                times = np.cumsum([0, Tru, Trd])
+                amplitudes = np.array([grad_start, Gp, grad_end])
+            else:
+                times = np.cumsum([0, Trd])
+                amplitudes = np.array([grad_start, grad_end])
+        else:
+            times = np.cumsum([0, Tru])
+            amplitudes = np.array([grad_start, grad_end])
+
+    grad = make_extended_trapezoid(
+        channel=channel, system=system, times=times, amplitudes=amplitudes
+    )
+    grad.area = __testGA1(Gp, Tru, Tp, Trd, grad_start, grad_end)
+
+    assert np.abs(grad.area - area) < 1e-3
+
+    return grad, times, amplitudes
+
+
+def __testGA(Gp, Tp, SR, dT, Gs, Ge):
+    Tru = np.ceil(np.abs(Gp - Gs) / SR / dT) * dT
+    Trd = np.ceil(np.abs(Gp - Ge) / SR / dT) * dT
+    ga = __testGA1(Gp, Tru, Tp, Trd, Gs, Ge)
+    return ga
+
+
+def __testGA1(Gp, Tru, Tp, Trd, Gs, Ge):
+    return 0.5 * Tru * (Gp + Gs) + Gp * Tp + 0.5 * (Gp + Ge) * Trd

+ 179 - 0
libs/lf-scanner/pypulseq/make_gauss_pulse.py

@@ -0,0 +1,179 @@
+import math
+from types import SimpleNamespace
+from typing import Tuple, Union
+
+import numpy as np
+
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.make_delay import make_delay
+from LF_scanner.pypulseq.make_trapezoid import make_trapezoid
+from LF_scanner.pypulseq.opts import Opts
+
+
+def make_gauss_pulse(
+    flip_angle: float,
+    apodization: float = 0,
+    bandwidth: float = 0,
+    center_pos: float = 0.5,
+    delay: float = 0,
+    dwell: float = 0,
+    duration: float = 4e-3,
+    freq_offset: float = 0,
+    max_grad: float = 0,
+    max_slew: float = 0,
+    phase_offset: float = 0,
+    return_gz: bool = False,
+    return_delay: bool = False,
+    slice_thickness: float = 0,
+    system: Opts = Opts(),
+    time_bw_product: float = 4,
+    use: str = str(),
+) -> Union[
+    SimpleNamespace,
+    Tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace],
+    Tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, SimpleNamespace],
+]:
+    """
+    Create a [optionally slice selective] Gauss pulse.
+
+    See also `pypulseq.Sequence.sequence.Sequence.add_block()`.
+
+    Parameters
+    ----------
+    flip_angle : float
+        Flip angle in radians.
+    apodization : float, default=0
+        Apodization.
+    bandwidth : float, default=0
+        Bandwidth in Hertz (Hz).
+    center_pos : float, default=0.5
+        Position of peak.
+    delay : float, default=0
+        Delay in seconds (s).
+    dwell : float, default=0
+    duration : float, default=4e-3
+        Duration in seconds (s).
+    freq_offset : float, default=0
+        Frequency offset in Hertz (Hz).
+    max_grad : float, default=0
+        Maximum gradient strength of accompanying slice select trapezoidal event.
+    max_slew : float, default=0
+        Maximum slew rate of accompanying slice select trapezoidal event.
+    phase_offset : float, default=0
+        Phase offset in Hertz (Hz).
+    return_delay : bool, default=False
+        Boolean flag to indicate if the delay event has to be returned.
+    return_gz : bool, default=False
+        Boolean flag to indicate if the slice-selective gradient has to be returned.
+    slice_thickness : float, default=0
+        Slice thickness of accompanying slice select trapezoidal event. The slice thickness determines the area of the
+        slice select event.
+    system : Opts, default=Opts()
+        System limits.
+    time_bw_product : int, default=4
+        Time-bandwidth product.
+    use : str, default=str()
+        Use of radio-frequency gauss pulse event. Must be one of 'excitation', 'refocusing' or 'inversion'.
+
+    Returns
+    -------
+    rf : SimpleNamespace
+        Radio-frequency gauss pulse event.
+    gz : SimpleNamespace, optional
+        Accompanying slice select trapezoidal gradient event.
+    gzr : SimpleNamespace, optional
+        Accompanying slice select rephasing trapezoidal gradient event.
+    delay : SimpleNamespace, optional
+        Delay event.
+
+    Raises
+    ------
+    ValueError
+        If invalid `use` is passed. Must be one of 'excitation', 'refocusing' or 'inversion'.
+        If `return_gz=True` and `slice_thickness` was not passed.
+    """
+    valid_use_pulses = ["excitation", "refocusing", "inversion"]
+    if use != "" and use not in valid_use_pulses:
+        raise ValueError(
+            f"Invalid use parameter. Must be one of 'excitation', 'refocusing' or 'inversion'. Passed: {use}"
+        )
+
+    if dwell == 0:
+        dwell = system.rf_raster_time
+
+    if bandwidth == 0:
+        BW = time_bw_product / duration
+    else:
+        BW = bandwidth
+    alpha = apodization
+    N = int(np.round(duration / dwell))
+    t = (np.arange(1, N + 1) - 0.5) * dwell
+    tt = t - (duration * center_pos)
+    window = 1 - alpha + alpha * np.cos(2 * np.pi * tt / duration)
+    signal = window * __gauss(BW * tt)
+    flip = np.sum(signal) * dwell * 2 * np.pi
+    signal = signal * flip_angle / flip
+
+    rf = SimpleNamespace()
+    rf.type = "rf"
+    rf.signal = signal
+    rf.t = t
+    rf.shape_dur = N * dwell
+    rf.freq_offset = freq_offset
+    rf.phase_offset = phase_offset
+    rf.dead_time = system.rf_dead_time
+    rf.ringdown_time = system.rf_ringdown_time
+    rf.delay = delay
+    if use != "":
+        rf.use = use
+
+    if rf.dead_time > rf.delay:
+        rf.delay = rf.dead_time
+
+    if return_gz:
+        if slice_thickness == 0:
+            raise ValueError("Slice thickness must be provided")
+
+        if max_grad > 0:
+            system.max_grad = max_grad
+
+        if max_slew > 0:
+            system.max_slew = max_slew
+
+        amplitude = BW / slice_thickness
+        area = amplitude * duration
+        gz = make_trapezoid(
+            channel="z", system=system, flat_time=duration, flat_area=area
+        )
+        gzr = make_trapezoid(
+            channel="z",
+            system=system,
+            area=-area * (1 - center_pos) - 0.5 * (gz.area - area),
+        )
+
+        if rf.delay > gz.rise_time:
+            gz.delay = (
+                np.ceil((rf.delay - gz.rise_time) / system.grad_raster_time)
+                * system.grad_raster_time
+            )
+
+        if rf.delay < (gz.rise_time + gz.delay):
+            rf.delay = gz.rise_time + gz.delay
+
+    if rf.ringdown_time > 0 and return_delay:
+        delay = make_delay(calc_duration(rf) + rf.ringdown_time)
+
+    # Following 2 lines of code are workarounds for numpy returning 3.14... for np.angle(-0.00...)
+    negative_zero_indices = np.where(rf.signal == -0.0)
+    rf.signal[negative_zero_indices] = 0
+
+    if return_gz and return_delay:
+        return rf, gz, gzr, delay
+    elif return_gz:
+        return rf, gz, gzr
+    else:
+        return rf
+
+
+def __gauss(x: np.ndarray) -> np.ndarray:
+    return np.exp(-np.pi * np.square(x))

+ 56 - 0
libs/lf-scanner/pypulseq/make_label.py

@@ -0,0 +1,56 @@
+from types import SimpleNamespace
+from typing import Union
+
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_labels
+
+
+def make_label(
+    label: str, type: str, value: Union[bool, float, int]
+) -> SimpleNamespace:
+    """
+    Create an ADC Label.
+
+    Parameters
+    ----------
+    type : str
+        Label type. Must be one of 'SET' or 'INC'.
+    label : str
+        Must be one of 'SLC', 'SEG', 'REP', 'AVG', 'SET', 'ECO', 'PHS', 'LIN', 'PAR', 'NAV', 'REV', or 'SMS'.
+    value : bool, float or int
+        Label value.
+
+    Returns
+    -------
+    out : SimpleNamespace
+        Label object.
+
+    Raises
+    ------
+    ValueError
+        If a valid `label` was not passed. Must be one of 'SLC', 'SEG', 'REP', 'AVG', 'SET', 'ECO', 'PHS', 'LIN', 'PAR',
+                                                                                                NAV', 'REV', or 'SMS'.
+        If a valid `type` was not passed. Must be one of 'SET' or 'INC'.
+        If `value` was not a valid numerical or logical value.
+    """
+    arr_supported_labels = get_supported_labels()
+
+    if label not in arr_supported_labels:
+        raise ValueError(
+            "Invalid label. Must be one of 'SLC', 'SEG', 'REP', 'AVG', 'SET', 'ECO', 'PHS', 'LIN', 'PAR', "
+            "NAV', 'REV', or 'SMS'."
+        )
+    if type not in ["SET", "INC"]:
+        raise ValueError("Invalid type. Must be one of 'SET' or 'INC'.")
+    if not isinstance(value, (bool, float, int)):
+        raise ValueError("Must supply a valid numerical or logical value.")
+
+    out = SimpleNamespace()
+    if type == "SET":
+        out.type = "labelset"
+    elif type == "INC":
+        out.type = "labelinc"
+
+    out.label = label
+    out.value = value
+
+    return out

+ 268 - 0
libs/lf-scanner/pypulseq/make_sigpy_pulse.py

@@ -0,0 +1,268 @@
+import math
+from types import SimpleNamespace
+from typing import Tuple, Union
+
+import numpy as np
+import sigpy.mri.rf as rf
+import sigpy.plot as pl
+
+from LF_scanner.pypulseq.make_trapezoid import make_trapezoid
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.sigpy_pulse_opts import SigpyPulseOpts
+
+
+def sigpy_n_seq(
+    flip_angle: float,
+    delay: float = 0,
+    duration: float = 4e-3,
+    freq_offset: float = 0,
+    center_pos: float = 0.5,
+    max_grad: float = 0,
+    max_slew: float = 0,
+    phase_offset: float = 0,
+    return_gz: bool = True,
+    slice_thickness: float = 0,
+    system: Opts = Opts(),
+    time_bw_product: float = 4,
+    pulse_cfg: SigpyPulseOpts = SigpyPulseOpts(),
+    use: str = str(),
+) -> Union[SimpleNamespace, Tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace]]:
+    """
+    Creates a radio-frequency sinc pulse event using the sigpy rf pulse library and optionally accompanying slice select, slice select rephasing
+    trapezoidal gradient events.
+
+    Parameters
+    ----------
+    flip_angle : float
+        Flip angle in radians.
+    apodization : float, optional, default=0
+        Apodization.
+    center_pos : float, optional, default=0.5
+        Position of peak.5 (midway).
+    delay : float, optional, default=0
+        Delay in seconds (s).
+    duration : float, optional, default=4e-3
+        Duration in seconds (s).
+    freq_offset : float, optional, default=0
+        Frequency offset in Hertz (Hz).
+    max_grad : float, optional, default=0
+        Maximum gradient strength of accompanying slice select trapezoidal event.
+    max_slew : float, optional, default=0
+        Maximum slew rate of accompanying slice select trapezoidal event.
+    phase_offset : float, optional, default=0
+        Phase offset in Hertz (Hz).
+    return_gz:bool, default=False
+        Boolean flag to indicate if slice-selective gradient has to be returned.
+    slice_thickness : float, optional, default=0
+        Slice thickness of accompanying slice select trapezoidal event. The slice thickness determines the area of the
+        slice select event.
+    system : Opts, optional
+        System limits. Default is a system limits object initialised to default values.
+    time_bw_product : float, optional, default=4
+        Time-bandwidth product.
+    use : str, optional, default=str()
+        Use of radio-frequency sinc pulse. Must be one of 'excitation', 'refocusing' or 'inversion'.
+
+    Returns
+    -------
+    rf : SimpleNamespace
+        Radio-frequency sinc pulse event.
+    gz : SimpleNamespace, optional
+        Accompanying slice select trapezoidal gradient event. Returned only if `slice_thickness` is provided.
+    gzr : SimpleNamespace, optional
+        Accompanying slice select rephasing trapezoidal gradient event. Returned only if `slice_thickness` is provided.
+
+    Raises
+    ------
+    ValueError
+        If invalid `use` parameter was passed. Must be one of 'excitation', 'refocusing' or 'inversion'.
+        If `return_gz=True` and `slice_thickness` was not provided.
+    """
+
+    valid_use_pulses = ["excitation", "refocusing", "inversion"]
+    if use != "" and use not in valid_use_pulses:
+        raise ValueError(
+            f"Invalid use parameter. Must be one of 'excitation', 'refocusing' or 'inversion'. Passed: {use}"
+        )
+
+    if pulse_cfg.pulse_type == "slr":
+        [signal, t, pulse] = make_slr(
+            flip_angle=flip_angle,
+            time_bw_product=time_bw_product,
+            duration=duration,
+            system=system,
+            pulse_cfg=pulse_cfg,
+            disp=True,
+        )
+    if pulse_cfg.pulse_type == "sms":
+        [signal, t, pulse] = make_sms(
+            flip_angle=flip_angle,
+            time_bw_product=time_bw_product,
+            duration=duration,
+            system=system,
+            pulse_cfg=pulse_cfg,
+            disp=True,
+        )
+
+    rfp = SimpleNamespace()
+    rfp.type = "rf"
+    rfp.signal = signal
+    rfp.t = t
+    rfp.freq_offset = freq_offset
+    rfp.phase_offset = phase_offset
+    rfp.dead_time = system.rf_dead_time
+    rfp.ringdown_time = system.rf_ringdown_time
+    rfp.delay = delay
+
+    if use != "":
+        rfp.use = use
+
+    if rfp.dead_time > rfp.delay:
+        rfp.delay = rfp.dead_time
+
+    if return_gz:
+        if slice_thickness == 0:
+            raise ValueError("Slice thickness must be provided")
+
+        if max_grad > 0:
+            system.max_grad = max_grad
+
+        if max_slew > 0:
+            system.max_slew = max_slew
+        BW = time_bw_product / duration
+        amplitude = BW / slice_thickness
+        area = amplitude * duration
+        gz = make_trapezoid(
+            channel="z", system=system, flat_time=duration, flat_area=area
+        )
+        gzr = make_trapezoid(
+            channel="z",
+            system=system,
+            area=-area * (1 - center_pos) - 0.5 * (gz.area - area),
+        )
+
+        if rfp.delay > gz.rise_time:
+            gz.delay = (
+                math.ceil((rfp.delay - gz.rise_time) / system.grad_raster_time)
+                * system.grad_raster_time
+            )
+
+        if rfp.delay < (gz.rise_time + gz.delay):
+            rfp.delay = gz.rise_time + gz.delay
+
+    if rfp.ringdown_time > 0:
+        t_fill = np.arange(1, round(rfp.ringdown_time / 1e-6) + 1) * 1e-6
+        rfp.t = np.concatenate((rfp.t, rfp.t[-1] + t_fill))
+        rfp.signal = np.concatenate((rfp.signal, np.zeros(len(t_fill))))
+
+    # Following 2 lines of code are workarounds for numpy returning 3.14... for np.angle(-0.00...)
+    negative_zero_indices = np.where(rfp.signal == -0.0)
+    rfp.signal[negative_zero_indices] = 0
+
+    if return_gz:
+        return rfp, gz, gzr, pulse
+    else:
+        return rfp
+
+
+def make_slr(
+    flip_angle: float,
+    time_bw_product: float = 4,
+    duration: float = 0,
+    system: Opts = Opts(),
+    pulse_cfg: SigpyPulseOpts = SigpyPulseOpts(),
+    disp: bool = False,
+):
+    N = int(round(duration / 1e-6))
+    t = np.arange(1, N + 1) * system.rf_raster_time
+
+    # Insert sigpy
+    ptype = pulse_cfg.ptype
+    ftype = pulse_cfg.ftype
+    d1 = pulse_cfg.d1
+    d2 = pulse_cfg.d2
+    cancel_alpha_phs = pulse_cfg.cancel_alpha_phs
+
+    pulse = rf.slr.dzrf(
+        n=N,
+        tb=time_bw_product,
+        ptype=ptype,
+        ftype=ftype,
+        d1=d1,
+        d2=d2,
+        cancel_alpha_phs=cancel_alpha_phs,
+    )
+    flip = np.sum(pulse) * system.rf_raster_time * 2 * np.pi
+    signal = pulse * flip_angle / flip
+
+    if disp:
+        pl.LinePlot(pulse)
+        pl.LinePlot(signal)
+
+        # Simulate it
+        [a, b] = rf.sim.abrm(
+            pulse,
+            np.arange(
+                -20 * time_bw_product, 20 * time_bw_product, 40 * time_bw_product / 2000
+            ),
+            True,
+        )
+        Mxy = 2 * np.multiply(np.conj(a), b)
+        pl.LinePlot(Mxy)
+
+    return signal, t, pulse
+
+
+def make_sms(
+    flip_angle: float,
+    time_bw_product: float = 4,
+    duration: float = 0,
+    system: Opts = Opts(),
+    pulse_cfg: SigpyPulseOpts = SigpyPulseOpts(),
+    disp: bool = False,
+):
+    N = int(round(duration / 1e-6))
+    t = np.arange(1, N + 1) * system.rf_raster_time
+
+    # Insert sigpy
+    ptype = pulse_cfg.ptype
+    ftype = pulse_cfg.ftype
+    d1 = pulse_cfg.d1
+    d2 = pulse_cfg.d2
+    cancel_alpha_phs = pulse_cfg.cancel_alpha_phs
+    n_bands = pulse_cfg.n_bands
+    band_sep = pulse_cfg.band_sep
+    phs_0_pt = pulse_cfg.phs_0_pt
+
+    pulse_in = rf.slr.dzrf(
+        n=N,
+        tb=time_bw_product,
+        ptype=ptype,
+        ftype=ftype,
+        d1=d1,
+        d2=d2,
+        cancel_alpha_phs=cancel_alpha_phs,
+    )
+    pulse = rf.multiband.mb_rf(
+        pulse_in, n_bands=n_bands, band_sep=band_sep, phs_0_pt=phs_0_pt
+    )
+
+    flip = np.sum(pulse) * system.rf_raster_time * 2 * np.pi
+    signal = pulse * flip_angle / flip
+
+    if disp:
+        pl.LinePlot(pulse_in)
+        pl.LinePlot(pulse)
+        pl.LinePlot(signal)
+        # Simulate it
+        [a, b] = rf.sim.abrm(
+            pulse,
+            np.arange(
+                -20 * time_bw_product, 20 * time_bw_product, 40 * time_bw_product / 2000
+            ),
+            True,
+        )
+        Mxy = 2 * np.multiply(np.conj(a), b)
+        pl.LinePlot(Mxy)
+
+    return signal, t, pulse

+ 172 - 0
libs/lf-scanner/pypulseq/make_sinc_pulse.py

@@ -0,0 +1,172 @@
+import math
+from types import SimpleNamespace
+from typing import Tuple, Union
+
+import numpy as np
+
+from LF_scanner.pypulseq import make_delay, calc_duration
+from LF_scanner.pypulseq.make_trapezoid import make_trapezoid
+from LF_scanner.pypulseq.opts import Opts
+from LF_scanner.pypulseq.supported_labels_rf_use import get_supported_rf_uses
+
+
+def make_sinc_pulse(
+    flip_angle: float,
+    apodization: float = 0,
+    delay: float = 0,
+    duration: float = 4e-3,
+    dwell: float = 0,
+    center_pos: float = 0.5,
+    freq_offset: float = 0,
+    max_grad: float = 0,
+    max_slew: float = 0,
+    phase_offset: float = 0,
+    return_delay: bool = False,
+    return_gz: bool = False,
+    slice_thickness: float = 0,
+    system: Opts = Opts(),
+    time_bw_product: float = 4,
+    use: str = str(),
+) -> Union[
+    SimpleNamespace,
+    Tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace],
+    Tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace, SimpleNamespace],
+]:
+    """
+    Creates a radio-frequency sinc pulse event and optionally accompanying slice select and slice select rephasing
+    trapezoidal gradient events.
+
+    Parameters
+    ----------
+    flip_angle : float
+        Flip angle in radians.
+    apodization : float, default=0
+        Apodization.
+    center_pos : float, default=0.5
+        Position of peak.5 (midway).
+    delay : float, default=0
+        Delay in seconds (s).
+    duration : float, default=4e-3
+        Duration in seconds (s).
+    dwell : float, default=0
+    freq_offset : float, default=0
+        Frequency offset in Hertz (Hz).
+    max_grad : float, default=0
+        Maximum gradient strength of accompanying slice select trapezoidal event.
+    max_slew : float, default=0
+        Maximum slew rate of accompanying slice select trapezoidal event.
+    phase_offset : float, default=0
+        Phase offset in Hertz (Hz).
+    return_delay : bool, default=False
+        Boolean flag to indicate if the delay event has to be returned.
+    return_gz : bool, default=False
+        Boolean flag to indicate if slice-selective gradient has to be returned.
+    slice_thickness : float, default=0
+        Slice thickness of accompanying slice select trapezoidal event. The slice thickness determines the area of the
+        slice select event.
+    system : Opts, default=Opts()
+        System limits. Default is a system limits object initialised to default values.
+    time_bw_product : float, default=4
+        Time-bandwidth product.
+    use : str, default=str()
+        Use of radio-frequency sinc pulse. Must be one of 'excitation', 'refocusing' or 'inversion'.
+
+    See also `pypulseq.Sequence.sequence.Sequence.add_block()`.
+
+    Returns
+    -------
+    rf : SimpleNamespace
+        Radio-frequency sinc pulse event.
+    gz : SimpleNamespace, optional
+        Accompanying slice select trapezoidal gradient event. Returned only if `slice_thickness` is provided.
+    gzr : SimpleNamespace, optional
+        Accompanying slice select rephasing trapezoidal gradient event. Returned only if `slice_thickness` is provided.
+
+    Raises
+    ------
+    ValueError
+        If invalid `use` parameter was passed. Must be one of 'excitation', 'refocusing' or 'inversion'.
+        If `return_gz=True` and `slice_thickness` was not provided.
+    """
+    valid_pulse_uses = get_supported_rf_uses()
+    if use != "" and use not in valid_pulse_uses:
+        raise ValueError(
+            f"Invalid use parameter. Must be one of {valid_pulse_uses}. Passed: {use}"
+        )
+
+    if dwell == 0:
+        dwell = system.rf_raster_time
+
+    if duration <= 0:
+        raise ValueError("RF pulse duration must be positive.")
+
+    BW = time_bw_product / duration
+    alpha = apodization
+    N = int(np.round(duration / dwell))
+    t = (np.arange(1, N + 1) - 0.5) * dwell
+    tt = t - (duration * center_pos)
+    window = 1 - alpha + alpha * np.cos(2 * np.pi * tt / duration)
+    signal = np.multiply(window, np.sinc(BW * tt))
+    flip = np.sum(signal) * dwell * 2 * np.pi
+    signal = signal * flip_angle / flip
+
+    rf = SimpleNamespace()
+    rf.type = "rf"
+    rf.signal = signal
+    rf.t = t
+    rf.shape_dur = N * dwell
+    rf.freq_offset = freq_offset
+    rf.phase_offset = phase_offset
+    rf.dead_time = system.rf_dead_time
+    rf.ringdown_time = system.rf_ringdown_time
+    rf.delay = delay
+
+    if use != str():
+        rf.use = use
+
+    if rf.dead_time > rf.delay:
+        rf.delay = rf.dead_time
+
+    if return_gz:
+        if slice_thickness == 0:
+            raise ValueError("Slice thickness must be provided")
+
+        if max_grad > 0:
+            system.max_grad = max_grad
+
+        if max_slew > 0:
+            system.max_slew = max_slew
+
+        amplitude = BW / slice_thickness
+        area = amplitude * duration
+        gz = make_trapezoid(
+            channel="z", system=system, flat_time=duration, flat_area=area
+        )
+        gzr = make_trapezoid(
+            channel="z",
+            system=system,
+            area=-area * (1 - center_pos) - 0.5 * (gz.area - area),
+        )
+
+        if rf.delay > gz.rise_time:
+            gz.delay = (
+                np.ceil((rf.delay - gz.rise_time) / system.grad_raster_time)
+                * system.grad_raster_time
+            )
+
+        if rf.delay < (gz.rise_time + gz.delay):
+            rf.delay = gz.rise_time + gz.delay
+
+    if rf.ringdown_time > 0 and return_delay:
+        delay = make_delay(calc_duration(rf) + rf.ringdown_time)
+
+    # Following 2 lines of code are workarounds for numpy returning 3.14... for np.angle(-0.00...)
+    negative_zero_indices = np.where(rf.signal == -0.0)
+    rf.signal[negative_zero_indices] = 0
+
+    if return_gz and return_delay:
+        return rf, gz, gzr, delay
+    elif return_gz:
+        return rf, gz, gzr
+    else:
+        return rf

+ 203 - 0
libs/lf-scanner/pypulseq/make_trapezoid.py

@@ -0,0 +1,203 @@
+from types import SimpleNamespace
+
+import numpy as np
+
+from LF_scanner.pypulseq.opts import Opts
+
+
+def make_trapezoid(
+    channel: str,
+    amplitude: float = 0,
+    area: float = None,
+    delay: float = 0,
+    duration: float = 0,
+    fall_time: float = 0,
+    flat_area: float = 0,
+    flat_time: float = -1,
+    max_grad: float = 0,
+    max_slew: float = 0,
+    rise_time: float = 0,
+    system: Opts = Opts(),
+) -> SimpleNamespace:
+    """
+    Create a trapezoidal gradient event.
+
+    See also:
+    - `pypulseq.Sequence.sequence.Sequence.add_block()`
+    - `pypulseq.opts.Opts`
+
+    Parameters
+    ----------
+    channel : str
+        Orientation of trapezoidal gradient event. Must be one of `x`, `y` or `z`.
+    amplitude : float, default=0
+        Amplitude.
+    area : float, default=None
+        Area.
+    delay : float, default=0
+        Delay in seconds (s).
+    duration : float, default=0
+        Duration in seconds (s).
+    flat_area : float, default=0
+        Flat area.
+    flat_time : float, default=-1
+        Flat duration in seconds (s). Default is -1 to account for triangular pulses.
+    max_grad : float, default=0
+        Maximum gradient strength.
+    max_slew : float, default=0
+        Maximum slew rate.
+    rise_time : float, default=0
+        Rise time in seconds (s).
+    system : Opts, default=Opts()
+        System limits.
+
+    Returns
+    -------
+    grad : SimpleNamespace
+        Trapezoidal gradient event created based on the supplied parameters.
+
+    Raises
+    ------
+    ValueError
+        If none of `area`, `flat_area` and `amplitude` are passed
+        If requested area is too large for this gradient
+        If `flat_time`, `duration` and `area` are not supplied.
+        Amplitude violation
+    """
+    if channel not in ["x", "y", "z"]:
+        raise ValueError(
+            f"Invalid channel. Must be one of `x`, `y` or `z`. Passed: {channel}"
+        )
+
+    if max_grad <= 0:
+        max_grad = system.max_grad
+
+    if max_slew <= 0:
+        max_slew = system.max_slew
+
+    if rise_time <= 0:
+        rise_time = 0.0
+
+    if fall_time > 0:
+        if rise_time == 0:
+            raise ValueError(
+                "Invalid arguments. Must always supply `rise_time` if `fall_time` is specified explicitly."
+            )
+    else:
+        fall_time = 0.0
+
+    if area is None and flat_area == 0 and amplitude == 0:
+        raise ValueError("Must supply either 'area', 'flat_area' or 'amplitude'.")
+
+    if flat_time != -1:
+        if amplitude != 0:
+            amplitude2 = amplitude
+        elif (area is not None) and (
+            rise_time > 0
+        ):  # We have rise_time, flat_time and area.
+            amplitude2 = area / (rise_time + flat_time)
+        else:
+            if flat_area == 0:
+                raise ValueError(
+                    "When `flat_time` is provided, either `flat_area` or `amplitude` must be provided as well; you may "
+                    "consider providing `duration`, `area` and optionally ramp times instead."
+                )
+            amplitude2 = flat_area / flat_time
+
+        if rise_time == 0:
+            rise_time = np.abs(amplitude2) / max_slew
+            rise_time = (
+                np.ceil(rise_time / system.grad_raster_time) * system.grad_raster_time
+            )
+            if rise_time == 0:
+                rise_time = system.grad_raster_time
+        if fall_time == 0:
+            fall_time = rise_time
+    elif duration > 0:
+        if amplitude == 0:
+            if rise_time == 0:
+                dC = 1 / np.abs(2 * max_slew) + 1 / np.abs(2 * max_slew)
+                possible = duration**2 > 4 * np.abs(area) * dC
+                assert possible, (
+                    f"Requested area is too large for this gradient. Minimum required duration is "
+                    f"{np.round(np.sqrt(4 * np.abs(area) * dC) * 1e6)} uss"
+                )
+                amplitude2 = (
+                    duration - np.sqrt(duration**2 - 4 * np.abs(area) * dC)
+                ) / (2 * dC)
+            else:
+                if fall_time == 0:
+                    fall_time = rise_time
+                amplitude2 = area / (duration - 0.5 * rise_time - 0.5 * fall_time)
+                possible = (
+                    duration > (rise_time + fall_time) and np.abs(amplitude2) < max_grad
+                )
+                assert possible, (
+                    f"Requested area is too large for this gradient. Probably amplitude is violated "
+                    f"{np.round(np.abs(amplitude) / max_grad * 100)}"
+                )
+
+        if rise_time == 0:
+            rise_time = (
+                np.ceil(np.abs(amplitude2) / max_slew / system.grad_raster_time)
+                * system.grad_raster_time
+            )
+            if rise_time == 0:
+                rise_time = system.grad_raster_time
+
+        if fall_time == 0:
+            fall_time = rise_time
+        flat_time = duration - rise_time - fall_time
+
+        if amplitude == 0:
+            # Adjust amplitude (after rounding) to match area
+            amplitude2 = area / (rise_time / 2 + fall_time / 2 + flat_time)
+    else:
+        if area == 0:
+            raise ValueError("Must supply area or duration.")
+        else:
+            # Find the shortest possible duration. First check if the area can be realized as a triangle.
+            # If not, then it must be a trapezoid.
+            rise_time = (
+                np.ceil(np.sqrt(np.abs(area) / max_slew) / system.grad_raster_time)
+                * system.grad_raster_time
+            )
+            if rise_time < system.grad_raster_time:  # Area was almost 0 maybe
+                rise_time = system.grad_raster_time
+            amplitude2 = np.divide(area, rise_time)  # To handle nan
+            t_eff = rise_time
+
+            if np.abs(amplitude2) > max_grad:
+                t_eff = (
+                    np.ceil(np.abs(area) / max_grad / system.grad_raster_time)
+                    * system.grad_raster_time
+                )
+                amplitude2 = area / t_eff
+                rise_time = (
+                    np.ceil(np.abs(amplitude2) / max_slew / system.grad_raster_time)
+                    * system.grad_raster_time
+                )
+
+                if rise_time == 0:
+                    rise_time = system.grad_raster_time
+
+            flat_time = t_eff - rise_time
+            fall_time = rise_time
+
+    if np.abs(amplitude2) > max_grad:
+        raise ValueError("Amplitude violation.")
+
+    grad = SimpleNamespace()
+    grad.type = "trap"
+    grad.channel = channel
+    grad.amplitude = amplitude2
+    grad.rise_time = rise_time
+    grad.flat_time = flat_time
+    grad.fall_time = fall_time
+    grad.area = amplitude2 * (flat_time + rise_time / 2 + fall_time / 2)
+    grad.flat_area = amplitude2 * flat_time
+    grad.delay = delay
+    grad.first = 0
+    grad.last = 0
+
+    return grad

+ 53 - 0
libs/lf-scanner/pypulseq/make_trigger.py

@@ -0,0 +1,53 @@
+# inserted for trigger support by mveldmann
+
+from types import SimpleNamespace
+
+from LF_scanner.pypulseq.opts import Opts
+
+
+def make_trigger(
+    channel: str, delay: float = 0, duration: float = 0, system: Opts = Opts()
+) -> SimpleNamespace:
+    """
+     Create a trigger halt event for a synchronisation with an external signal from a given channel with an optional
+     given delay prio to the sync and duration after the sync. Possible channel values: 'physio1','physio2'
+     (Siemens specific).
+
+    See also `pypulseq.Sequence.sequence.Sequence.add_block()`.
+
+     Parameters
+     ----------
+     channel : str
+         Must be one of 'physio1' or 'physio2'.
+     delay : float, default=0
+         Delay in seconds
+     duration: float, default=0
+         Duration in seconds.
+     system : Opts, default=Opts()
+         System limits.
+
+     Returns
+     -------
+     trigger : SimpleNamespace
+         Trigger event.
+
+     Raises
+     ------
+     ValueError
+         If invalid `channel` is passed. Must be one of 'physio1' or 'physio2'.
+    """
+
+    if channel not in ["physio1", "physio2"]:
+        raise ValueError(
+            f"Channel {channel} is invalid. Must be one of 'physio1' or 'physio2'."
+        )
+
+    trigger = SimpleNamespace()
+    trigger.type = "trigger"
+    trigger.channel = channel
+    trigger.delay = delay
+    trigger.duration = duration
+    if trigger.duration <= system.grad_raster_time:
+        trigger.duration = system.grad_raster_time
+
+    return trigger

+ 110 - 0
libs/lf-scanner/pypulseq/opts.py

@@ -0,0 +1,110 @@
+from LF_scanner.pypulseq.convert import convert
+
+
+class Opts:
+    """
+    System limits of an MR scanner.
+
+    Attributes
+    ----------
+    adc_dead_time : float, default=0
+        Dead time for ADC readout pulses.
+    gamma : float, default=42.576e6
+        Gyromagnetic ratio. Default gamma is specified for Hydrogen.
+    grad_raster_time : float, default=10e-6
+        Raster time for gradient waveforms.
+    grad_unit : str, default='Hz/m'
+        Unit of maximum gradient amplitude. Must be one of 'Hz/m', 'mT/m' or 'rad/ms/mm'.
+    max_grad : float, default=0
+        Maximum gradient amplitude.
+    max_slew : float, default=0
+        Maximum slew rate.
+    rf_dead_time : float, default=0
+        Dead time for radio-frequency pulses.
+    rf_raster_time : float, default=1e-6
+        Raster time for radio-frequency pulses.
+    rf_ringdown_time : float, default=0
+        Ringdown time for radio-frequency pulses.
+    rise_time : float, default=0
+        Rise time for gradients.
+    slew_unit : str, default='Hz/m/s'
+        Unit of maximum slew rate. Must be one of 'Hz/m/s', 'mT/m/ms', 'T/m/s' or 'rad/ms/mm/ms'.
+
+    Raises
+    ------
+    ValueError
+        If invalid `grad_unit` is passed. Must be one of 'Hz/m', 'mT/m' or 'rad/ms/mm'.
+        If invalid `slew_unit` is passed. Must be one of 'Hz/m/s', 'mT/m/ms', 'T/m/s' or 'rad/ms/mm/ms'.
+    """
+
+    def __init__(
+        self,
+        adc_dead_time: float = 0,
+        adc_raster_time: float = 100e-9,
+        block_duration_raster: float = 10e-6,
+        gamma: float = 42.576e6,
+        grad_raster_time: float = 10e-6,
+        grad_unit: str = "Hz/m",
+        max_grad: float = 0,
+        max_slew: float = 0,
+        rf_dead_time: float = 0,
+        rf_raster_time: float = 1e-6,
+        rf_ringdown_time: float = 0,
+        rise_time: float = 0,
+        slew_unit: str = "Hz/m/s",
+        B0: float = 1.5,
+    ):
+        valid_grad_units = ["Hz/m", "mT/m", "rad/ms/mm"]
+        valid_slew_units = ["Hz/m/s", "mT/m/ms", "T/m/s", "rad/ms/mm/ms"]
+
+        if grad_unit not in valid_grad_units:
+            raise ValueError(
+                f"Invalid gradient unit. Must be one of 'Hz/m', 'mT/m' or 'rad/ms/mm'. "
+                f"Passed: {grad_unit}"
+            )
+
+        if slew_unit not in valid_slew_units:
+            raise ValueError(
+                f"Invalid slew rate unit. Must be one of 'Hz/m/s', 'mT/m/ms', 'T/m/s' or 'rad/ms/mm/ms'. "
+                f"Passed: {slew_unit}"
+            )
+
+        if max_grad == 0:
+            max_grad = convert(from_value=40, from_unit="mT/m", gamma=gamma)
+        else:
+            max_grad = convert(
+                from_value=max_grad, from_unit=grad_unit, to_unit="Hz/m", gamma=gamma
+            )
+
+        if max_slew == 0:
+            max_slew = convert(from_value=170, from_unit="T/m/s", gamma=gamma)
+        else:
+            max_slew = convert(
+                from_value=max_slew, from_unit=slew_unit, to_unit="Hz/m", gamma=gamma
+            )
+
+        if rise_time != 0:
+            max_slew = max_grad / rise_time
+
+        self.max_grad = max_grad
+        self.max_slew = max_slew
+        self.rise_time = rise_time
+        self.rf_dead_time = rf_dead_time
+        self.rf_ringdown_time = rf_ringdown_time
+        self.adc_dead_time = adc_dead_time
+        self.adc_raster_time = adc_raster_time
+        self.rf_raster_time = rf_raster_time
+        self.grad_raster_time = grad_raster_time
+        self.block_duration_raster = block_duration_raster
+        self.gamma = gamma
+        self.B0 = B0
+
+    def __str__(self) -> str:
+        """
+        Print a string representation of the system limits objects.
+        """
+        variables = vars(self)
+        s = [f"{key}: {value}" for key, value in variables.items()]
+        s = "\n".join(s)
+        s = "System limits:\n" + s
+        return s

+ 41 - 0
libs/lf-scanner/pypulseq/points_to_waveform.py

@@ -0,0 +1,41 @@
+import numpy as np
+
+
+def points_to_waveform(
+    amplitudes: np.ndarray, grad_raster_time: float, times: np.ndarray
+) -> np.ndarray:
+    """
+    1D interpolate amplitude values `amplitudes` at time indices `times` as per the gradient raster time
+    `grad_raster_time` to generate a gradient waveform.
+
+    Parameters
+    ----------
+    amplitudes : numpy.ndarray
+        Amplitude values at time indices `times`.
+    grad_raster_time : float
+        Gradient raster time.
+    times : numpy.ndarray
+        Time indices.
+
+    Returns
+    -------
+    waveform : numpy.ndarray
+        Gradient waveform.
+    """
+
+    amplitudes = np.asarray(amplitudes)
+    times = np.asarray(times)
+
+    if amplitudes.size == 0:
+        return np.array([0])
+
+    grd = (
+        np.arange(
+            start=np.round(np.min(times) / grad_raster_time),
+            stop=np.round(np.max(times) / grad_raster_time),
+        )
+        * grad_raster_time
+    )
+    waveform = np.interp(x=grd + grad_raster_time / 2, xp=times, fp=amplitudes)
+
+    return waveform

+ 20 - 0
libs/lf-scanner/pypulseq/recon_examples/2dFFT.py

@@ -0,0 +1,20 @@
+import numpy as np
+from matplotlib import pyplot as plt
+
+from dat2py import dat2py_main
+
+path = r"C:\Users\sravan953\Downloads\FINAL_meas_MID00169_FID00800_pulseq_3D_mprage.dat"
+kspace, img = dat2py_main.main(dat_file_path=path)
+img = np.abs(np.sqrt(np.sum(np.square(img), -1)))
+
+plt.imshow(img)
+plt.show()
+
+
+def main():
+    path = r"C:\Users\sravan953\Desktop\20210424_7datas\gre_meas_MID00176_FID00172_pulseq.dat"
+    # kspace, img = dat2py_main.main(dat_file_path=path)
+
+
+if __name__ == '__main__':
+    main()

+ 0 - 0
libs/lf-scanner/pypulseq/recon_examples/__init__.py


+ 123 - 0
libs/lf-scanner/pypulseq/rotate.py

@@ -0,0 +1,123 @@
+from types import SimpleNamespace
+from typing import List
+
+import numpy as np
+
+from LF_scanner.pypulseq.add_gradients import add_gradients
+from LF_scanner.pypulseq.scale_grad import scale_grad
+
+
+def __get_grad_abs_mag(grad: SimpleNamespace) -> np.ndarray:
+    if grad.type == "trap":
+        return np.abs(grad.amplitude)
+    return np.max(np.abs(grad.waveform))
+
+
+def rotate(
+    *args: SimpleNamespace,
+    angle: float,
+    axis: str,
+) -> List[SimpleNamespace]:
+    """
+    Rotates the corresponding gradient(s) about the given axis by the specified amount. Gradients parallel to the
+    rotation axis and non-gradient(s) are not affected. Possible rotation axes are 'x', 'y' or 'z'.
+
+    See also `pypulseq.Sequence.sequence.add_block()`.
+
+    Parameters
+    ----------
+    axis : str
+        Axis about which the gradient(s) will be rotated.
+    angle : float
+        Angle by which the gradient(s) will be rotated.
+    args : SimpleNamespace
+        Gradient(s).
+
+    Returns
+    -------
+    rotated_grads : [SimpleNamespace]
+        Rotated gradient(s).
+    """
+    axes = ["x", "y", "z"]
+
+    # Cycle through the objects and rotate gradients non-parallel to the given rotation axis. Rotated gradients
+    # assigned to the same axis are then added together.
+
+    # First create indexes of the objects to be bypassed or rotated
+    i_rotate1 = []
+    i_rotate2 = []
+    i_bypass = []
+
+    axes.remove(axis)
+    axes_to_rotate = axes
+    if len(axes_to_rotate) != 2:
+        raise ValueError("Incorrect axes specification.")
+
+    for i in range(len(args)):
+        event = args[i]
+
+        if (event.type != "grad" and event.type != "trap") or event.channel == axis:
+            i_bypass.append(i)
+        else:
+            if event.channel == axes_to_rotate[0]:
+                i_rotate1.append(i)
+            else:
+                if event.channel == axes_to_rotate[1]:
+                    i_rotate2.append(i)
+                else:
+                    i_bypass.append(i)  # Should never happen
+
+    # Now every gradient to be rotated generates two new gradients: one on the original axis and one on the other from
+    # the axes_to_rotate list
+    rotated1 = []
+    rotated2 = []
+    max_mag = 0  # Measure of relevant amplitude
+    for i in range(len(i_rotate1)):
+        g = args[i_rotate1[i]]
+        max_mag = np.max((max_mag, __get_grad_abs_mag(g)))
+        rotated1.append(scale_grad(grad=g, scale=np.cos(angle)))
+        g = scale_grad(grad=g, scale=np.sin(angle))
+        g.channel = axes_to_rotate[1]
+        rotated2.append(g)
+
+    for i in range(len(i_rotate2)):
+        g = args[i_rotate2[i]]
+        max_mag = np.max((max_mag, __get_grad_abs_mag(g)))
+        rotated2.append(scale_grad(grad=g, scale=np.cos(angle)))
+        g = scale_grad(grad=g, scale=-np.sin(angle))
+        g.channel = axes_to_rotate[1]
+        rotated1.append(g)
+
+    # Eliminate zero-amplitude gradients
+    threshold = 1e-6 * max_mag
+    for i in range(len(rotated1) - 1, -1, -1):
+        if __get_grad_abs_mag(rotated1[i]) < threshold:
+            rotated1.pop(i)
+    for i in range(len(rotated2) - 1, -1, -1):
+        if __get_grad_abs_mag(rotated2[i]) < threshold:
+            rotated2.pop(i)
+
+    # Add gradients on the corresponding axis together
+    g = []
+    if len(rotated1) > 1:
+        g.append(add_gradients(grads=rotated1))
+    else:
+        if len(rotated1) != 0:
+            g.append(rotated1[0])
+
+    if len(rotated2) > 1:
+        g.append(add_gradients(grads=rotated2))
+    else:
+        if len(rotated2) != 0:
+            g.append(rotated2[0])
+
+    # Eliminate zero amplitude gradients
+    for i in range(len(g) - 1, -1, -1):
+        if __get_grad_abs_mag(g[i]) < threshold:
+            g.pop(i)
+
+    # Export
+    bypass = np.take(args, i_bypass)
+    rotated_grads = [*bypass, *g]
+
+    return rotated_grads

+ 35 - 0
libs/lf-scanner/pypulseq/scale_grad.py

@@ -0,0 +1,35 @@
+from copy import copy
+from types import SimpleNamespace
+
+
+def scale_grad(grad: SimpleNamespace, scale: float) -> SimpleNamespace:
+    """
+    Scales the gradient with the scalar.
+
+    Parameters
+    ----------
+    grad : SimpleNamespace
+        Gradient event to be scaled.
+    scale : float
+        Scaling factor.
+
+    Returns
+    -------
+    grad : SimpleNamespace
+        Scaled gradient.
+    """
+    # copy() to emulate pass-by-value; otherwise passed grad event is modified
+    scaled_grad = copy(grad)
+    if scaled_grad.type == "trap":
+        scaled_grad.amplitude = scaled_grad.amplitude * scale
+        scaled_grad.area = scaled_grad.area * scale
+        scaled_grad.flat_area = scaled_grad.flat_area * scale
+    else:
+        scaled_grad.waveform = scaled_grad.waveform * scale
+        scaled_grad.first = scaled_grad.first * scale
+        scaled_grad.last = scaled_grad.last * scale
+
+    if hasattr(scaled_grad, "id"):
+        delattr(scaled_grad, "id")
+
+    return scaled_grad

+ 0 - 0
libs/lf-scanner/pypulseq/seq_examples/__init__.py


+ 0 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/__init__.py


+ 339 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_FS_TSE_T1_T2_PD.py

@@ -0,0 +1,339 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_gauss_pulse import make_gauss_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+def FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    params['FS_sat_ppm'] = -3.45  # TODO add to GUI
+    params['FS_pulse_duration'] = 8e-3  # TODO add to GUI
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+
+    rf_fs = make_gauss_pulse(flip_angle=flip_fs, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(FS_sat_frequency), freq_offset=FS_sat_frequency)
+    gx_fs = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= g_rf_area, rise_time=params['dG'])
+    gy_fs = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= g_rf_area, rise_time=params['dG'])
+
+    return rf_fs, gx_fs, gy_fs
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append((ETL - 1) * n_ex - i * n_ex)
+    # print(k_space_list_with_zero)
+    central_num = np.int32(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+    return k_space_order_filing
+
+
+def main(plot: bool, write_seq: bool, weightning, FS: bool):
+
+    # Reading json file according to the weightning of the image
+    if weightning == 'T1': #TODO: create general path
+        if FS:
+            with open('C:\MRI_seq_files_mess\FS_TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+
+    elif weightning == 'T2':
+        if FS:
+            with open('C:\MRI_seq_files_mess\FS_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+
+    elif weightning == 'PD':
+        if FS:
+            with open('C:\MRI_seq_files_mess\FS_TSE_PD.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE_PD.json', 'rb') as f:
+                params = j.load(f)
+    else:
+        print('Please choose image weightning')
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'][0], #TODO: change format from list to float in GUI
+        rf_dead_time=params['rf_dead_time'][0], #TODO: change format from list to float in GUI
+        adc_dead_time=params['adc_dead_time'][0], #TODO: change format from list to float in GUI
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    flip_fs = round(110 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+    rf90.delay = params['dG']
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+    rf180.delay = params['dG']
+
+    if FS: #TODO add to GUI choise of including or not Fat Sat block
+        g_rf_area = gz_ex.area * 10
+        rf_fs, gx_fs, gy_fs = FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']))  # TODO: to create additiolal functions on different k space order filling
+    k_space_order_filing
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL']) - 1):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+    block_duration += calc_duration(gz_cr)
+    if FS:
+        block_duration += calc_duration(gx_fs)
+
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    # rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    # rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+                    if FS:
+                        seq.add_block(gx_fs, gy_fs, rf_fs)
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_cr, gx_spoil, gy_pre)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq: #TODO: create general path
+        if FS:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE\\t1_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE\\t2_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE')
+
+            elif weightning == 'PD':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE\\pd_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE')
+
+            else:
+                print('Please choose image weightning')
+        else:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\FS_t1_TSE\\FS_t1_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_FS_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\FS_t2_TSE\\FS_t2_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_FS_TSE')
+
+            elif weightning == 'PD':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\FS_pd_TSE\\FS_pd_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_FS_TSE')
+            else:
+                print('Please choose image weightning')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, weightning='T1', FS=True)

+ 287 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_HASTE_T2.py

@@ -0,0 +1,287 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number, part_fourier_factor):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append(int((ETL - 1) * n_ex - i * n_ex))
+    # print(k_space_list_with_zero)
+    central_num = int(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    last_line_num = partial_Fourier(part_fourier_factor, k_space_list_with_zero, n_ex*ETL)
+    k_space_list_with_zero = k_space_list_with_zero[:last_line_num]
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+    return k_space_order_filing
+
+def partial_Fourier(part_fourier_factor, k_space_order_filing, Np):
+    num_k_lines = Np * part_fourier_factor
+    np.int32(num_k_lines)
+    if (np.int32(num_k_lines) % 2) == 0:
+        num_k_lines = np.int32(num_k_lines)
+    else:
+        num_k_lines = np.int32(num_k_lines) + 1
+
+    return num_k_lines
+
+
+def main(plot: bool, write_seq: bool):
+
+    # Reading json file according to the weightning of the image
+    with open('C:\MRI_seq_files_mess\HASTE_T2.json', 'rb') as f:
+        params = j.load(f)
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    params['part_fourier_factor'] = 1 # TODO add to GUI
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']), params['part_fourier_factor'])  # TODO: to create additiolal functions on different k space order filling
+    k_space_save = {'k_space_order': k_space_order_filing}
+
+    output_filename = "k_space_order_filing"  # save for reconstruction outside the jemris
+    # output_filename = "TSE_T1" + datetime.now().strftime("%Y%m%d_%H%M%S")
+    with open(output_filename + ".json", 'w') as outfile:
+        j.dump(k_space_save, outfile)
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL']) - 1):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+    block_duration += calc_duration(gz_cr)
+    params['delay_TD'] = 0.200 # delay    # TODO add to GUI
+                                          # TODO
+    TD_delay = make_delay(params['delay_TD'])
+
+
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    # rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    # rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_cr, gx_spoil, gy_pre)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(TD_delay)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq: #TODO: create general path
+        seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_HASTE\\t2_HASTE_matrix16x16.seq')  # Save to disk
+        seq2xml(seq, seq_name='t2_HASTE_matrix16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_HASTE')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 396 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_IR_TSE_T1_T2.py

@@ -0,0 +1,396 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_gauss_pulse import make_gauss_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+def FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    params['FS_sat_ppm'] = -3.45  # TODO add to GUI
+    params['FS_pulse_duration'] = 8e-3  # TODO add to GUI
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+
+    rf_fs = make_gauss_pulse(flip_angle=flip_fs, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(FS_sat_frequency), freq_offset=FS_sat_frequency)
+    gx_fs = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= g_rf_area, rise_time=params['dG'])
+    gy_fs = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= g_rf_area, rise_time=params['dG'])
+
+    return rf_fs, gx_fs, gy_fs
+
+def inversion_block(params, scanner_parameters):
+    #function creates inversion recovery block with delay
+    params['IR_time'] = 0.140  # STIR # TODO add to GUI
+    #params['IR_time'] = 2.250  # FLAIR # TODO add to GUI
+    flip_ir = round(180 * pi / 180)
+    rf_ir, gz_ir, _ = make_sinc_pulse(flip_angle=flip_ir, system=scanner_parameters, duration=params['t_ref'],
+                                      slice_thickness=params['sl_thkn'], apodization=0.3,
+                                      time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                      return_gz=True)
+    delay_IR = np.ceil(params['IR_time'] / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    delay_IR = make_delay(delay_IR)
+
+    return rf_ir, gz_ir, delay_IR
+
+def SPAIR_block(params, scanner_parameters, g_rf_area):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    params['FS_sat_ppm'] = -3.45  # TODO add to GUI
+    params['FS_pulse_duration'] = 8e-3  # TODO add to GUI
+    params['IR_time'] = 0.140  # SPAIR # TODO add to GUI
+
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+
+    rf_SPAIR = make_gauss_pulse(flip_angle=flip_fs, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(FS_sat_frequency), freq_offset=FS_sat_frequency)
+    gx_SPAIR = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= g_rf_area, rise_time=params['dG'])
+    gy_SPAIR = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= g_rf_area, rise_time=params['dG'])
+
+    delay_IR = np.ceil(params['IR_time'] / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    delay_IR = make_delay(delay_IR)
+
+    return rf_fs, gx_fs, gy_fs
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append((ETL - 1) * n_ex - i * n_ex)
+    # print(k_space_list_with_zero)
+    central_num = np.int32(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+    return k_space_order_filing
+
+
+def main(plot: bool, write_seq: bool, weightning, FS: bool, IR: bool):
+
+    # Reading json file according to the weightning of the image
+    if weightning == 'T1': #TODO: create general path
+        if FS:
+            with open('C:\MRI_seq_files_mess\FS_TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+        elif IR:
+            with open('C:\MRI_seq_files_mess\IR_TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+
+    elif weightning == 'T2':
+        if FS:
+            with open('C:\MRI_seq_files_mess\FS_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        elif IR:
+            with open('C:\MRI_seq_files_mess\IR_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+
+    elif weightning == 'PD':
+        if FS:
+            with open('C:\MRI_seq_files_mess\FS_TSE_PD.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE_PD.json', 'rb') as f:
+                params = j.load(f)
+    else:
+        print('Please choose image weightning')
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'][0], #TODO: change format from list to float in GUI
+        rf_dead_time=params['rf_dead_time'][0], #TODO: change format from list to float in GUI
+        adc_dead_time=params['adc_dead_time'][0], #TODO: change format from list to float in GUI
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    flip_fs = round(110 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    if FS: #TODO add to GUI choise of including or not Fat Sat block
+        g_rf_area = gz_ex.area * 10
+        rf_fs, gx_fs, gy_fs = FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs)
+
+    if IR: #TODO add to GUI choise of including or not Inversion block
+        rf_ir, gz_ir, delay_IR = inversion_block(params, scanner_parameters)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']))  # TODO: to create additiolal functions on different k space order filling
+    k_space_order_filing
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL']) - 1):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+    block_duration += calc_duration(gz_cr)
+    if FS:
+        block_duration += calc_duration(gx_fs)
+    if IR:
+        block_duration += max(calc_duration(rf_ir), calc_duration(gz_ir))
+        block_duration += calc_duration(delay_IR)
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    # rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    # rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+                    if FS:
+                        seq.add_block(gx_fs, gy_fs, rf_fs)
+                    if IR:
+                        seq.add_block(gz_ir, rf_ir)
+                        seq.add_block(delay_IR)
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_cr, gx_spoil, gy_pre)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq: #TODO: create general path
+        if FS:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_FS_TSE\\FS_t1_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_FS_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_FS_TSE\\FS_t2_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_FS_TSE')
+
+            elif weightning == 'PD':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_FS_TSE\\FS_pd_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_FS_TSE')
+
+            else:
+                print('Please choose image weightning')
+        elif IR:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_IR_TSE\\IR_t1_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='IR_t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_IR_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_IR_TSE\\IR_t2_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='IR_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_IR_TSE')
+            else:
+                print('Please choose image weightning')
+        else:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE\\t1_TSE_matrx16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE\\t2_TSE_matrix16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE')
+
+            elif weightning == 'PD':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE\\pd_TSE_matrix16x16.seq')  # Save to disk
+                seq2xml(seq, seq_name='pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE')
+            else:
+                print('Please choose image weightning')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, weightning='T2', FS=False, IR=False)

+ 213 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_SE.py

@@ -0,0 +1,213 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#----------------------------------------------
+from math import pi
+
+import numpy as np
+import math
+import json as j
+
+from MRI_seq.pypulseq.Sequence.sequence import Sequence
+from MRI_seq.pypulseq.calc_rf_center import calc_rf_center
+from MRI_seq.pypulseq.calc_duration import calc_duration
+from MRI_seq.pypulseq.make_adc import make_adc
+from MRI_seq.pypulseq.make_delay import make_delay
+from MRI_seq.pypulseq.make_sinc_pulse import make_sinc_pulse
+from MRI_seq.pypulseq.make_trapezoid import make_trapezoid
+from MRI_seq.pypulseq.opts import Opts
+
+from MRI_seq.py2jemris.seq2xml import seq2xml
+from utilities import phase_grad_utils as pgu
+
+def main(plot: bool, write_seq: bool):
+
+    # Read parameters
+    with open('C:\MRI_seq_files_mess\\TSE\\SE_T1.json', 'rb') as f:
+        params = j.load(f)
+
+    tau = params['TE'] / 2
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # Set the hardware limits and initialize sequence object
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    # RF objects
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                    slice_thickness=params['sl_thkn'], apodization=0.3,
+                                    time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                      slice_thickness=params['sl_thkn'], apodization=0.3,
+                                      time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                      return_gz=True)
+
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz90.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz180.amplitude
+
+    # Slice selection gradients
+    # gz_reph rephase gradient after gz90
+    t_gz_reph = np.ceil(params['t_ex'] / 2 / params['grad_raster_time']) * params['grad_raster_time']
+    gz_reph = make_trapezoid(channel='z', system=scanner_parameters, flat_area=-gz90.area / 2,
+                             flat_time=t_gz_reph, rise_time=params['dG'])
+    t_gz_spoil = np.ceil(params['t_ref'] / 2 / params['grad_raster_time']) * params['grad_raster_time']
+
+    # gz_spoil spoil gradients around
+    # gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area*3,
+    #                         duration=t_gz_spoil*2.5)
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'])
+    gz_sps = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4)
+
+    # READOUT gradients & events
+
+    # k-space readout
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+
+    # generate gx readout gradient
+    t_gx = np.ceil(readout_time / params['grad_raster_time']) * params['grad_raster_time']
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx)
+
+    # generate gx_pre readout prephase gradient
+    # t_gx_pre = np.ceil(readout_time / 2 / params['grad_raster_time']) * params['grad_raster_time']
+    gx_pre = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area * 1.5, rise_time=params['dG'])
+
+    # generate gx spoile gradient
+    # t_gx_spoil = np.ceil(readout_time / 2 / params['grad_raster_time']) * params['grad_raster_time']
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, rise_time=params['dG'])
+
+    # generate ADC block
+    gx.rise_time = np.ceil(gx.rise_time / params['grad_raster_time']) * params['grad_raster_time']
+    gx.flat_time = np.ceil(gx.flat_time / params['grad_raster_time']) * params['grad_raster_time']
+
+    adc = make_adc(num_samples=params['Nf'], duration=gx.flat_time, delay=gx.rise_time, system=scanner_parameters)
+
+    # PREPHASE AND REPHASE
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))
+
+    t_gy_pre = np.ceil(params['t_ex'] / 2 / params['grad_raster_time']) * params['grad_raster_time']
+    k_phase
+    gy_pre = make_trapezoid(channel='y', system=scanner_parameters, area=k_steps_PE[-1],
+                            duration=t_gy_pre)
+
+    # DELAYS
+    delay1 = tau - calc_duration(gz90) / 2 - max(calc_duration(gx_pre), calc_duration(gy_pre), calc_duration(gz_reph))
+    delay1 -= calc_duration(gz_spoil)
+    delay1 -= calc_duration(gz180) / 2
+    delay1 = np.ceil(delay1 / params['grad_raster_time']) * params['grad_raster_time']
+    delay1 = make_delay(delay1)
+
+    delay2 = tau - calc_duration(gz180) / 2 - calc_duration(gz_spoil)
+    delay2 -= calc_duration(gx_spoil)
+    delay2 -= calc_duration(gx) / 2
+    delay2 = np.ceil(delay2 / params['grad_raster_time']) * params['grad_raster_time']
+    delay2 = make_delay(delay2)
+
+    delay_TR = params['TR'] - calc_duration(gz90) / 2 - calc_duration(gx) / 2 - params['TE']
+    delay_TR -= max(calc_duration(gy_pre), calc_duration(gz_sps))
+    delay_TR -= calc_duration(gx_spoil)
+    delay_TR = np.ceil(delay_TR / params['grad_raster_time']) * params['grad_raster_time']
+    delay_TR = make_delay(delay_TR)
+
+    print(f'delay_1: {delay1}')
+    print(f'delay_2: {delay1}')
+    print(f'delay_TR: {delay_TR}')
+
+    # CONSTRUCT CONCATINATIONS timings
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = params['TR'] - delay_TR.delay  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [max(params['grad_raster_time'], params['rf_raster_time']) * np.floor(
+        x / max(params['grad_raster_time'], params['rf_raster_time'])) for x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # CONSTRUCT SEQUENCE
+
+    # working version without concatinations
+
+    for k in range(np.int32(params['Average'])):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_step in range(k_steps_PE.size):
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    # rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    # rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+
+                    seq.add_block(rf90, gz90)
+                    t_gy_pre = np.ceil(params['t_ex'] / 2 / params['grad_raster_time']) * params['grad_raster_time']
+                    gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                            area=k_steps_PE[phase_step], duration=t_gy_pre)
+                    seq.add_block(gx_pre, gy_pre, gz_reph)
+                    seq.add_block(delay1)
+                    seq.add_block(gz_spoil)
+                    seq.add_block(rf180, gz180)
+                    seq.add_block(gz_spoil)
+                    seq.add_block(delay2)
+                    seq.add_block(gx_spoil)
+                    seq.add_block(gx, adc)
+                    seq.add_block(gx_spoil)
+                    gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                            area=-k_steps_PE[phase_step], duration=t_gy_pre)
+                    seq.add_block(gy_pre, gz_sps)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    if plot:
+        seq.plot()
+
+    if write_seq:
+        seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_se\\t1_SE_matrx32x32.seq')
+        seq2xml(seq, seq_name='t1_SE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_se')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 452 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_SPAIR_TSE_T2.py

@@ -0,0 +1,452 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_gauss_pulse import make_gauss_pulse
+from pypulseq.make_adiabatic_pulse import make_adiabatic_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+def FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    #params['FS_sat_ppm'] = -3.30  # TODO add to GUI
+    #params['FS_pulse_duration'] = 8e-3  # TODO add to GUI
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+
+    rf_fs = make_gauss_pulse(flip_angle=flip_fs, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(params['BW_sat']), freq_offset=FS_sat_frequency)
+    gx_fs = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= 4*g_rf_area, rise_time=params['dG'])
+    gy_fs = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= 4*g_rf_area, rise_time=params['dG'])
+
+    return rf_fs, gx_fs, gy_fs
+
+def inversion_block(params, scanner_parameters):
+    #function creates inversion recovery block with delay
+    #params['IR_time'] = 0.140  # STIR # TODO add to GUI
+    #params['IR_time'] = 2.250  # FLAIR # TODO add to GUI
+    flip_ir = round(180 * pi / 180)
+    rf_ir, gz_ir, _ = make_sinc_pulse(flip_angle=flip_ir, system=scanner_parameters, duration=params['t_ref'],
+                                      slice_thickness=params['sl_thkn'], apodization=0.3,
+                                      time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                      return_gz=True)
+    delay_IR = np.ceil(params['TI'] / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    delay_IR = make_delay(delay_IR)
+
+    return rf_ir, gz_ir, delay_IR
+
+def SPAIR_block(params, scanner_parameters, g_rf_area):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    params['FS_sat_ppm'] = -3.30  # TODO add to GUI
+    params['FS_pulse_duration'] = 0.01  # TODO add to GUI
+    #params['IR_time'] = 0.140  # SPAIR # TODO add to GUI
+    params['BW_sat'] = -176.26464
+
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+    flip_SPAIR = round(180 * pi / 180)
+
+    rf_SPAIR = make_gauss_pulse(flip_angle=flip_SPAIR, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(params['BW_sat']), freq_offset=FS_sat_frequency)
+    gx_SPAIR = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_SPAIR),
+                           area= g_rf_area, rise_time=params['dG'])
+    gy_SPAIR = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_SPAIR),
+                           area= g_rf_area, rise_time=params['dG'])
+
+    delay_IR = np.ceil(params['TI'] / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    delay_IR = make_delay(delay_IR)
+
+    return rf_SPAIR, gx_SPAIR, gy_SPAIR, delay_IR
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number, order):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append(int((ETL - 1) * n_ex - i * n_ex))
+    # print(k_space_list_with_zero)
+    central_num = int(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    if order == 'non_linear':
+        a = k_space_list_with_zero[:((shift-index_central_line)*2+1)]
+        b = k_space_list_with_zero[((shift-index_central_line)*2+1):]
+        for i in range(1, int(len(b)/2)+1):
+            a.append(b[i-1])
+            a.append(b[-i])
+        a.append(b[i])
+        k_space_list_with_zero = a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+
+    return k_space_order_filing
+
+
+def main(plot: bool, write_seq: bool, weightning, FS: bool, IR: bool, SPAIR):
+
+    # Reading json file according to the weightning of the image
+    if weightning == 'T1': #TODO: create general path
+        if FS:
+            with open('C:\MRI_seq_files_mess\TSE\FS_TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+        elif IR:
+            with open('C:\MRI_seq_files_mess\TSE\IR_TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE\TSE_T1.json', 'rb') as f:
+                params = j.load(f)
+
+    elif weightning == 'T2':
+        if FS:
+            with open('C:\MRI_seq_files_mess\TSE\FS_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        elif IR:
+            with open('C:\MRI_seq_files_mess\TSE\IR_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        elif SPAIR:
+            with open('C:\MRI_seq_files_mess\TSE\SPAIR_TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE\TSE_T2.json', 'rb') as f:
+                params = j.load(f)
+
+    elif weightning == 'PD':
+        if FS:
+            with open('C:\MRI_seq_files_mess\TSE\FS_TSE_PD.json', 'rb') as f:
+                params = j.load(f)
+        else:
+            with open('C:\MRI_seq_files_mess\TSE\TSE_PD.json', 'rb') as f:
+                params = j.load(f)
+    else:
+        print('Please choose image weightning')
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    flip_fs = round(110 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    if FS: #TODO add to GUI choise of including or not Fat Sat block
+        g_rf_area = gz_ex.area * 10
+        rf_fs, gx_fs, gy_fs = FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs)
+
+    if IR: #TODO add to GUI choise of including or not Inversion block
+        rf_ir, gz_ir, delay_IR = inversion_block(params, scanner_parameters)
+
+    if SPAIR: #TODO add to GUI choise of including or not Inversion block
+        g_rf_area = gz_ex.area * 10
+        rf_SPAIR, gx_SPAIR, gy_SPAIR, delay_IR = SPAIR_block(params, scanner_parameters, g_rf_area)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+    gz_cr2 = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 12, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']), 'non_linear')  # TODO: to create additiolal functions on different k space order filling
+    k_space_save = {'k_space_order': k_space_order_filing}
+
+    output_filename = "k_space_order_filing"  #save for reconstruction outside the jemris
+    # output_filename = "TSE_T1" + datetime.now().strftime("%Y%m%d_%H%M%S")
+    with open(output_filename + ".json", 'w') as outfile:
+        j.dump(k_space_save, outfile)
+
+
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL']) - 1):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+    block_duration += calc_duration(gz_cr)
+    block_duration += calc_duration(gz_cr2)
+
+    if FS:
+        block_duration += calc_duration(gx_fs)
+    if IR:
+        block_duration += max(calc_duration(rf_ir), calc_duration(gz_ir))
+        block_duration += calc_duration(delay_IR)
+    if SPAIR:
+        block_duration += calc_duration(gx_SPAIR)
+        block_duration += calc_duration(delay_IR)
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    if max_slices_per_TR == 0:
+        max_slices_per_TR = 1
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+                    crasher_flag = True
+                    if FS:
+                        seq.add_block(gz_cr2)
+                        seq.add_block(gx_fs, gy_fs, rf_fs)
+                        crasher_flag = False
+
+                    if IR:
+                        seq.add_block(gz_cr2)
+                        seq.add_block(gz_ir, rf_ir)
+                        seq.add_block(delay_IR)
+                        crasher_flag = False
+
+                    if SPAIR:
+                        seq.add_block(gz_cr2)
+                        seq.add_block(gx_SPAIR, gy_SPAIR, rf_SPAIR)
+                        seq.add_block(delay_IR)
+                        crasher_flag = False
+
+                    if crasher_flag:
+                        seq.add_block(gz_cr2)
+
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_cr, gx_spoil, gy_pre)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq: #TODO: create general path
+        if FS:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_FS_TSE\\FS_t1_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_FS_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_FS_TSE\\FS_t2_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_FS_TSE')
+
+            elif weightning == 'PD':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_FS_TSE\\FS_pd_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='FS_pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_FS_TSE')
+
+            else:
+                print('Please choose image weightning')
+        elif IR:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_IR_TSE\\IR_t1_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='IR_t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_IR_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_IR_TSE\\IR_t2_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='IR_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_IR_TSE')
+            else:
+                print('Please choose image weightning')
+        elif SPAIR:
+            if weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_SPAIR_TSE\\SPAIR_t2_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='SPAIR_t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_SPAIR_TSE')
+
+        else:
+            if weightning == 'T1':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE\\t1_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+            elif weightning == 'T2':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE\\t2_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE')
+
+            elif weightning == 'PD':
+                seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE\\pd_TSE_matrix32x32.seq')  # Save to disk
+                seq2xml(seq, seq_name='pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE')
+            else:
+                print('Please choose image weightning')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, weightning='T2', FS=False, IR=False, SPAIR=True)

+ 289 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_TSE_T1_T2_PD.py

@@ -0,0 +1,289 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append((ETL - 1) * n_ex - i * n_ex)
+    # print(k_space_list_with_zero)
+    central_num = np.int32(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+    return k_space_order_filing
+
+def partial_Fourier(a):
+    return a
+
+
+def main(plot: bool, write_seq: bool, weightning):
+
+    # Reading json file according to the weightning of the image
+    if weightning == 'T1': #TODO: create general path
+        with open('C:\MRI_seq_files_mess\TSE_T1.json', 'rb') as f:
+            params = j.load(f)
+
+    elif weightning == 'T2':
+        with open('C:\MRI_seq_files_mess\TSE_T2.json', 'rb') as f:
+            params = j.load(f)
+
+    elif weightning == 'PD':
+        with open('C:\MRI_seq_files_mess\TSE_PD.json', 'rb') as f:
+            params = j.load(f)
+    else:
+        print('Please choose image weightning')
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'][0],
+        rf_dead_time=params['rf_dead_time'][0],
+        adc_dead_time=params['adc_dead_time'][0],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']))  # TODO: to create additiolal functions on different k space order filling
+    k_space_order_filing
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL']) - 1):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+    block_duration += calc_duration(gz_cr)
+
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    # rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    # rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_cr, gx_spoil, gy_pre)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq: #TODO: create general path
+        if weightning == 'T1':
+            seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE\\t1_TSE_matrx16x16.seq')  # Save to disk
+            seq2xml(seq, seq_name='t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+        elif weightning == 'T2':
+            seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE\\t2_TSE_matrx16x16.seq')  # Save to disk
+            seq2xml(seq, seq_name='t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE')
+
+        elif weightning == 'PD':
+            seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE\\pd_TSE_matrx16x16.seq')  # Save to disk
+            seq2xml(seq, seq_name='pd_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE')
+
+        else:
+            print('Please choose image weightning')
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, weightning='T1')

+ 289 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_TSE_T2_RESTORE.py

@@ -0,0 +1,289 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append(int((ETL - 1) * n_ex - i * n_ex))
+    # print(k_space_list_with_zero)
+    central_num = int(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+    return k_space_order_filing
+
+
+def main(plot: bool, write_seq: bool, weightning):
+
+    # Reading json file according to the weightning of the image
+    if weightning == 'T2':
+        with open('C:\MRI_seq_files_mess\TSE\RESTORE_T2.json', 'rb') as f:
+            params = j.load(f)
+    else:
+        print('exists only for T2')
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    # Restore RF pulse -90
+    rf_restore, gz_resto, _ = make_sinc_pulse(flip_angle=-flip90, system=scanner_parameters, duration=params['t_ex'],
+                                              slice_thickness=params['sl_thkn'], apodization=0.3,
+                                              time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+    pulse_offsets_restore = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                        params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_resto.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_restore = t_exwd
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+    gz_restore = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_resto.amplitude,
+                                flat_time=t_restore, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']))  # TODO: to create additiolal functions on different k space order filling
+    k_space_save = {'k_space_order': k_space_order_filing}
+    output_filename = "k_space_order_filing"  # save for reconstruction outside the jemris
+    # output_filename = "TSE_T1" + datetime.now().strftime("%Y%m%d_%H%M%S")
+    with open(output_filename + ".json", 'w') as outfile:
+        j.dump(k_space_save, outfile)
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL'])):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz180))
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(rf90), calc_duration(gz90))
+    block_duration += calc_duration(gz_cr)
+
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    rf_restore.freq_offset = pulse_offsets_restore[curr_slice]
+                    rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                            seq.add_block(gz180, rf180)
+                            seq.add_block(gz_reph, gx_pre)
+                            seq.add_block(gz_restore, rf_restore)
+                            seq.add_block(gz_cr)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if weightning == 'T2':
+        seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE_RESTORE\\t2_TSE_RESTORE_matrix32x32.seq')  # Save to disk
+        seq2xml(seq, seq_name='t2_TSE_RESTORE_matrx32x32',
+                out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE_RESTORE')
+
+    else:
+        print('works only with T2')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, weightning='T2')

+ 264 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_epi_SE_T2.py

@@ -0,0 +1,264 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+import matplotlib as plt
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_gauss_pulse import make_gauss_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+def FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    #params['FS_sat_ppm'] = -3.30  # TODO add to GUI
+    #params['FS_pulse_duration'] = 8e-3  # TODO add to GUI
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+
+    rf_fs = make_gauss_pulse(flip_angle=flip_fs, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(params['BW_sat']), freq_offset=FS_sat_frequency)
+    #TODO
+    #rf_fs.phaseOffset=-2*pi*rf_fs.freqOffset*mr.calcRfCenter(rf_fs)
+    gx_fs = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= 4*g_rf_area, rise_time=params['dG'])
+    gy_fs = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= 4*g_rf_area, rise_time=params['dG'])
+
+    return rf_fs, gx_fs, gy_fs
+
+
+def main(plot: bool, write_seq: bool, FS: bool, kplot: bool, seq_filename: str = "epi_se_pypulseq.seq"):
+
+    # Reading json file according to the weightning of the image
+    with open('C:\MRI_seq_files_mess\TSE\EPI_T2.json', 'rb') as f:
+        params = j.load(f)
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    flip_fs = round(110 * pi / 180)
+
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    if FS: #TODO add to GUI choise of including or not Fat Sat block
+        g_rf_area = gz_ex.area * 10
+        rf_fs, gx_fs, gy_fs = FS_CHESS_block(params, scanner_parameters, g_rf_area, flip_fs)
+
+        # generate basic blip gradient - G_blip
+    k_blip = 1 / np.double(params['FoV_ph'])
+    ty_blip = math.ceil(2 * math.sqrt(
+        k_blip / params['G_slew_max']) / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gy_blip = make_trapezoid(channel='y', system=scanner_parameters, area=k_blip)
+
+    # generate basic gx readout gradient - G_read
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+
+
+    # read prephase gradient - G_pre_r
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=-gx.area * 0.50,
+                            rise_time=params['dG'])
+
+    #gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 0.50 - 1 / np.double(params['FoV_f'] /2),
+    #                        rise_time=params['dG'])
+
+    # phase prephase gradient - G_pre_ph
+    gy_pre = make_trapezoid(channel="y", system=scanner_parameters, area=-(params['Np']/2-1)/params['FoV_ph'])
+
+    # pre
+    gz_reph = make_trapezoid(channel='z', system=scanner_parameters, area=-gz90.area/2)
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil1 = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area, rise_time=params['dG'],
+                              flat_time=params['dG'])
+    gz_spoil2 = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 1.5, rise_time=params['dG'])
+
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    a = np.ceil(readout_time/ 4 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time + a, system=scanner_parameters)
+
+    #---------------------------
+    # Diffusion Gradient
+    #---------------------------
+    big_delta = params['TE']/2 - (max(calc_duration(rf90), calc_duration(gz90)))/2 + max(calc_duration(rf180), calc_duration(gz180))/2 + max(calc_duration(gy_pre), calc_duration(gz_spoil2))
+    c = calc_duration(gx)
+    t_to_center = (params['Nf']/2+0.5) * calc_duration(gx) + params['Np'] / 2 * calc_duration(gy_blip)
+    t_to_center = np.ceil(t_to_center / params['grad_raster_time']) * params['grad_raster_time']
+
+    t_diff = params['TE']/2 - max(calc_duration(rf180), calc_duration(gz180))/2 - params['dG'] - max(calc_duration(gy_pre), calc_duration(gz_spoil2)) - calc_duration(gx_pre) - t_to_center
+    small_delta = t_diff - params['dG']
+    params['G_amp_max']
+    G_amp_max = params['G_amp_max'] * 1e-3 * params['gamma']
+    small_delta_min = math.sqrt(params['b'] / (big_delta - small_delta / 3)) * 1 / (2 * pi * G_amp_max) * 1e3
+    G_diff = math.sqrt(params['b'] / (big_delta - small_delta / 3)) * 1 / (2 * pi * small_delta) * 1e3
+    gz_diffusion = make_trapezoid(channel='z', system=scanner_parameters, flat_time =small_delta, amplitude=G_diff)
+    a = calc_duration(gz_diffusion)
+
+    #----------------------------
+    #calculate delays
+    #----------------------------
+    delay_TE1 = params['TE']/2 - (max(calc_duration(rf90), calc_duration(gz90)))/2 - max(calc_duration(rf180), calc_duration(gz180))/2 - calc_duration(gz_spoil1) - calc_duration(gz_diffusion)
+    delay_TE2 = params['TE']/2 - (max(calc_duration(rf180), calc_duration(gz180)))/2 - calc_duration(gz_spoil2) - calc_duration(gz_diffusion) - t_to_center - calc_duration(gy_pre)
+    delay_TE1 = np.ceil(delay_TE1 / params['grad_raster_time']) * params['grad_raster_time']
+    delay_TE2 = np.ceil(delay_TE2 / params['grad_raster_time']) * params['grad_raster_time']
+
+    delay_TE1 = make_delay(delay_TE1)
+    delay_TE2 = make_delay(delay_TE2)
+
+    block_duration = params['TE'] + t_to_center + calc_duration(gz_cr)
+    if FS:
+        block_duration += calc_duration(gx_fs)
+
+    # --------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    # --------------------------
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                rf90.freq_offset = pulse_offsets90[curr_slice]
+                rf180.freq_offset = pulse_offsets180[curr_slice]
+                rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                print('curr_concat_' + str(curr_concat))
+                print('curr_slice_' + str(curr_slice))
+
+                if FS:
+                    seq.add_block(gx_fs, gy_fs, rf_fs)
+                seq.add_block(gz90, rf90)
+                seq.add_block(gz_diffusion)
+                seq.add_block(delay_TE1)
+                seq.add_block(gz_spoil1)
+                seq.add_block(gz180, rf180)
+                seq.add_block(gz_spoil2)
+                seq.add_block(gz_diffusion)
+                seq.add_block(delay_TE2)
+                seq.add_block(gx_pre, gy_pre)
+                for i in range(params['Np']):
+                    seq.add_block(gx, adc)  # Read one line of k-space
+                    seq.add_block(gy_blip)  # Phase blip
+                    gx.amplitude = -gx.amplitude  # Reverse polarity of read gradient
+                seq.add_block(gz_cr)
+                seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed! Error listing follows:")
+        print(error_report)
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    if kplot:
+        ktraj_adc, ktraj, t_excitation, t_refocusing, t_adc = seq.calculate_kspace()
+
+        time_axis = np.arange(1, ktraj.shape[1] + 1) * scanner_parameters.grad_raster_time
+        plt.plot(time_axis, ktraj.T)
+        plt.plot(t_adc, ktraj_adc[0, :], '.')
+        plt.figure()
+        plt.plot(ktraj[0, :], ktraj[1, :], 'b')
+        plt.axis('equal')
+        plt.plot(ktraj_adc[0, :], ktraj_adc[1, :], 'r.')
+        plt.show()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, kplot = False, FS=True)

+ 286 - 0
libs/lf-scanner/pypulseq/seq_examples/new_scripts/write_tse.py

@@ -0,0 +1,286 @@
+#---------------------------------------------------------------------
+# imports of the libraries
+#---------------------------------------------------------------------
+from math import pi
+import numpy as np
+import math
+import json as j
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.calc_duration import calc_duration
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.opts import Opts
+from pypulseq.align import align
+from pypulseq.traj_to_grad import traj_to_grad
+
+from pypulseq.utilities import phase_grad_utils as pgu
+
+from py2jemris.seq2xml import seq2xml
+
+
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append((ETL - 1) * n_ex - i * n_ex)
+    # print(k_space_list_with_zero)
+    central_num = np.int32(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+    return k_space_order_filing
+
+
+def main(plot: bool, write_seq: bool, weightning):
+
+    # Reading json file according to the weightning of the image
+    if weightning == 'T1':
+        with open('C:\MRI_seq_files_mess\TSE_T1.json', 'rb') as f:
+            params = j.load(f)
+
+    elif weightning == 'T2':
+        with open('C:\MRI_seq_files_mess\TSE_T2.json', 'rb') as f:
+            params = j.load(f)
+
+    elif weightning == 'T1':
+        with open('C:\MRI_seq_files_mess\TSE_T1.json', 'rb') as f:
+            params = j.load(f)
+    else:
+        print('Please choose image weightning')
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+
+    # --------------------------
+    # Set system limits
+    # --------------------------
+
+    scanner_parameters = Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+    seq = Sequence(scanner_parameters)
+
+    #--------------------------
+    # RF & Gradients
+    #--------------------------
+
+    rf90_phase = np.pi / 2
+    rf180_phase = 0
+
+    flip90 = round(params['FA'] * pi / 180, 3)
+    flip180 = round(180 * pi / 180)
+    rf90, gz_ex, _ = make_sinc_pulse(flip_angle=flip90, system=scanner_parameters, duration=params['t_ex'],
+                                     slice_thickness=params['sl_thkn'], apodization=0.3,
+                                     time_bw_product=round(params['t_BW_product_ex'], 8), return_gz=True)
+
+    rf180, gz_ref, _ = make_sinc_pulse(flip_angle=flip180, system=scanner_parameters, duration=params['t_ref'],
+                                       slice_thickness=params['sl_thkn'], apodization=0.3,
+                                       time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                       return_gz=True)
+
+    # Prepare RF offsets. This is required for multi-slice acquisition
+    pulse_offsets90 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                  params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ex.amplitude
+    pulse_offsets180 = (np.linspace(0.0, params['sl_nb'] - 1.0, np.int16(params['sl_nb'])) - 0.5 * (
+                np.double(params['sl_nb']) - 1.0)) * (
+                                   params['sl_thkn'] * (100.0 + params['sl_gap']) / 100.0) * gz_ref.amplitude
+
+    # slice selective gradient drafts for complex gradient blocks
+    t_exwd = params['t_ex'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+    t_refwd = params['t_ref'] + scanner_parameters.rf_ringdown_time + scanner_parameters.rf_dead_time
+
+    gz90 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ex.amplitude,
+                          flat_time=t_exwd, rise_time=params['dG'])
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=t_refwd, rise_time=params['dG'])
+
+    # generate basic gx readout gradient - G_read
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx + 2 * scanner_parameters.adc_dead_time)
+
+    # generate gx spoiler gradient - G_crr
+    gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area, flat_time=params['dG'],
+                              rise_time=params['dG'])
+
+    # read prephase gradient - G_pre
+    gx_pre = make_trapezoid(channel="x", system=scanner_parameters, area=gx.area * 1.50,
+                            rise_time=params['dG'])
+
+    # rephase gradient draft after 90 RF pulse  - G_reph
+    gz_reph = make_trapezoid(channel="z", system=scanner_parameters, area=gz_ex.area * 0.25,
+                             flat_time=calc_duration(gx_pre), rise_time=params['dG'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    t_gz_spoil = np.ceil(
+        params['t_ref'] / 2 / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gz_spoil = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 0.75, rise_time=params['dG'],
+                              flat_time=params['dG'])
+
+    # spoil gradient G_sps
+    gz_cr = make_trapezoid(channel='z', system=scanner_parameters, area=gz90.area * 4, rise_time=params['dG'])
+
+    # Creation of ADC
+    adc = make_adc(num_samples=params['Nf'], duration=t_gx, delay=scanner_parameters.adc_dead_time,
+                   system=scanner_parameters)
+
+    #--------------------------
+    # k-space filling quantification
+    #--------------------------
+
+    k_phase = np.double(params['Np']) / np.double(params['FoV_ph'])
+    k_steps_PE = pgu.create_k_steps(k_phase, np.int16(params['Np']))  # list of phase encoding gradients
+
+    n_ex = math.floor(params['Np'] / params['ETL'])  # number of excitations
+    k_space_order_filing = TSE_k_space_fill(n_ex, np.int32(params['ETL']), np.int32(params['Np']), np.int32(
+        params['N_TE']))  # TODO: create function on different k space order filling
+    k_space_order_filing
+
+    #--------------------------
+    # DELAYS
+    #--------------------------
+
+    block_duration = 0
+    block_duration = max(calc_duration(rf90), calc_duration(gz90)) / 2
+    block_duration += max(calc_duration(gx_pre), calc_duration(gz_spoil))
+    for i in range(np.int32(params['ETL']) - 1):
+        block_duration += max(calc_duration(rf180), calc_duration(gz180))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+        block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+        block_duration += calc_duration(gz_spoil)
+    block_duration += max(calc_duration(rf180), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(gz_spoil))
+    block_duration += max(calc_duration(gx_spoil), calc_duration(adc))
+    block_duration += calc_duration(gz_cr)
+
+    #--------------------------
+    # CONSTRUCT CONCATINATIONS timings
+    #--------------------------
+
+    # Quantification of Effective TE loop
+    # eff_time = TE + calc_duration(gx) / 2 + max(calc_duration(gy_pre),calc_duration(gz_spoil)) + calc_duration(gx_spoil) + calc_duration(gz90) / 2
+    eff_time = block_duration  # equal to previous!
+
+    max_slices_per_TR = np.floor(params['TR'] / eff_time)
+    required_concats = np.int32(np.ceil(params['sl_nb'] / max_slices_per_TR))
+    slice_list = list(range(np.int32(params['sl_nb'])))
+    slice_list = [slice_list[x::required_concats] for x in range(required_concats)]
+
+    # Calculate the TR fillers
+    tr_pauses = [(params['TR'] / np.double(len(x))) - eff_time for x in slice_list]
+    tr_pauses = [
+        max(seq.grad_raster_time, seq.rf_raster_time) * np.floor(x / max(seq.grad_raster_time, seq.rf_raster_time)) for
+        x in tr_pauses]
+
+    # Generate the TR fillers
+    tr_fillers = [make_delay(x) for x in tr_pauses]
+
+    # --------------------------
+    # CONSTRUCT SEQUENCE
+    # --------------------------
+
+    for k in range(params['Average']):  # Averages
+        for curr_concat in range(required_concats):
+            for phase_steps in k_space_order_filing:  # in stead of phase steps list of phase steps
+                for curr_slice in range(np.int32(params['sl_nb'])):  # Slices
+                    # Apply RF offsets
+                    n_echo_temp = 0
+                    rf90.freq_offset = pulse_offsets90[curr_slice]
+                    rf180.freq_offset = pulse_offsets180[curr_slice]
+                    # rf90.phase_offset = (rf90_phase - 2 * np.pi * rf90.freq_offset * calc_rf_center(rf90)[0])
+                    # rf180.phase_offset = (rf180_phase - 2 * np.pi * rf180.freq_offset * calc_rf_center(rf180)[0])
+                    print('curr_concat_' + str(curr_concat))
+                    print('curr_slice_' + str(curr_slice))
+
+                    seq.add_block(gz90, rf90)
+                    seq.add_block(gz_reph, gx_pre)
+                    for phase_step in phase_steps:
+                        print('phase step_' + str(phase_step))
+                        seq.add_block(gz180, rf180)
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=-k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        print(k_steps_PE[phase_step])
+
+                        seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                        seq.add_block(gx, adc)
+                        n_echo_temp += 1
+                        gy_pre = make_trapezoid(channel='y', system=scanner_parameters,
+                                                area=k_steps_PE[phase_step], duration=calc_duration(gz_spoil),
+                                                rise_time=params['dG'])
+                        if n_echo_temp == np.int32(params['ETL']):
+                            seq.add_block(gz_cr, gx_spoil, gy_pre)
+                        else:
+                            seq.add_block(gz_spoil, gx_spoil, gy_pre)
+                    seq.add_block(tr_fillers[curr_concat])
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        if weightning == 'T1':
+            seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE\\t1_TSE_matrx16x16.seq')  # Save to disk
+            seq2xml(seq, seq_name='t1_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+        elif weightning == 'T2':
+            seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE\\t2_TSE_matrx16x16.seq')  # Save to disk
+            seq2xml(seq, seq_name='t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE')
+
+        elif weightning == 'PD':
+            seq.write('C:\\MRI_seq\\new_MRI_pulse_seq\\pd_TSE\\pd_TSE_matrx16x16.seq')  # Save to disk
+            seq2xml(seq, seq_name='t2_TSE_matrx16x16_myGrad', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t2_TSE')
+
+        else:
+            print('Please choose image weightning')
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True, weightning='T1')

+ 449 - 0
libs/lf-scanner/pypulseq/seq_examples/notebooks/write_t2_se.ipynb

@@ -0,0 +1,449 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "MiKvRj5u076V"
+   },
+   "source": [
+    "## **ABOUT**\n",
+    "This example illustrates the 2D multi-slice, Spin Echo (SE) acquisition using the `pypulseq` library. This sequence is typically used for T<sub>2</sub> weighted imaging. A 2D Fourier transform can be used to reconstruct images from this acquisition. Read more about SE [here](http://mriquestions.com/se-vs-multi-se-vs-fse.html).\n",
+    "\n",
+    "**Contact**: For issues, write to ks3621@columbia.edu\n",
+    "\n",
+    "---"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "Y98YDJr215fa"
+   },
+   "source": [
+    "## **INSTALL** `pypulseq`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "ogKNAZH3TmgA"
+   },
+   "outputs": [],
+   "source": [
+    "!pip install git+https://github.com/imr-framework/pypulseq.git@dev"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "UgqzEwle2xCd"
+   },
+   "source": [
+    "## **IMPORT PACKAGES**"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "3X7UsV832B6j"
+   },
+   "outputs": [],
+   "source": [
+    "from math import pi\n",
+    "\n",
+    "import numpy as np\n",
+    "\n",
+    "from pypulseq.Sequence.sequence import Sequence\n",
+    "from pypulseq.calc_duration import calc_duration\n",
+    "from pypulseq.make_adc import make_adc\n",
+    "from pypulseq.make_delay import make_delay\n",
+    "from pypulseq.make_sinc_pulse import make_sinc_pulse\n",
+    "from pypulseq.make_trapezoid import make_trapezoid\n",
+    "from pypulseq.opts import Opts"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "UQ4AWw9l4et_"
+   },
+   "source": [
+    "## **USER INPUTS**\n",
+    "\n",
+    "These parameters are typically on the user interface of the scanner computer console "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "ssnNwiQH4q_0"
+   },
+   "outputs": [],
+   "source": [
+    "nsa = 1  # Number of averages\n",
+    "n_slices = 3  # Number of slices\n",
+    "Nx = 128\n",
+    "Ny = 128\n",
+    "fov = 220e-3  # mm\n",
+    "slice_thickness = 5e-3  # s\n",
+    "slice_gap = 15e-3  # s\n",
+    "rf_flip = 90  # degrees\n",
+    "rf_offset = 0\n",
+    "print('User inputs setup')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "PeYeI0V45ZfD"
+   },
+   "source": [
+    "## **SYSTEM LIMITS**\n",
+    "Set the hardware limits and initialize sequence object"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "XHs1LT965kqg"
+   },
+   "outputs": [],
+   "source": [
+    "system = Opts(max_grad=32, grad_unit='mT/m', max_slew=130, slew_unit='T/m/s', \n",
+    "              grad_raster_time=10e-6, rf_ringdown_time=10e-6, \n",
+    "              rf_dead_time=100e-6)\n",
+    "seq = Sequence(system)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "ee-xBrpa7Zyn"
+   },
+   "source": [
+    "## **TIME CONSTANTS**"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "u2dW2nRf7obq"
+   },
+   "outputs": [],
+   "source": [
+    "TE = 100e-3  # s\n",
+    "TR = 3  # s\n",
+    "tau = TE / 2  # s\n",
+    "readout_time = 6.4e-3\n",
+    "pre_time = 8e-4  # s"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "OTw7M03g79bH"
+   },
+   "source": [
+    "## **RF**"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "XDZyQrbL8I3Q"
+   },
+   "outputs": [],
+   "source": [
+    "flip90 = round(rf_flip * pi / 180, 3)\n",
+    "flip180 = 180 * pi / 180\n",
+    "rf90, gz90, _ = make_sinc_pulse(flip_angle=flip90, system=system, duration=4e-3, \n",
+    "                                slice_thickness=slice_thickness, apodization=0.5, \n",
+    "                                time_bw_product=4, return_gz = True)\n",
+    "rf180, gz180, _ = make_sinc_pulse(flip_angle=flip180, system=system, \n",
+    "                                  duration=2.5e-3, \n",
+    "                                  slice_thickness=slice_thickness, \n",
+    "                                  apodization=0.5, \n",
+    "                                time_bw_product=4, phase_offset=90 * pi/180, return_gz = True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "RFSHuUOG9LHK"
+   },
+   "source": [
+    "## **READOUT**\n",
+    "Readout gradients and related events"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "Q8p-CttI9dk9"
+   },
+   "outputs": [],
+   "source": [
+    "delta_k = 1 / fov\n",
+    "k_width = Nx * delta_k\n",
+    "gx = make_trapezoid(channel='x', system=system, flat_area=k_width, \n",
+    "                    flat_time=readout_time)\n",
+    "adc = make_adc(num_samples=Nx, duration=gx.flat_time, delay=gx.rise_time)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "o829kzm8kVFB"
+   },
+   "source": [
+    "## **PREPHASE AND REPHASE**"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "m5zA1bMakTVs"
+   },
+   "outputs": [],
+   "source": [
+    "phase_areas = (np.arange(Ny) - (Ny / 2)) * delta_k\n",
+    "gz_reph = make_trapezoid(channel='z', system=system, area=-gz90.area / 2,\n",
+    "                         duration=2.5e-3)\n",
+    "gx_pre = make_trapezoid(channel='x', system=system, flat_area=k_width / 2, \n",
+    "                        flat_time=readout_time / 2)\n",
+    "gy_pre = make_trapezoid(channel='y', system=system, area=phase_areas[-1], \n",
+    "                        duration=2e-3)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "5Css5esAkYHo"
+   },
+   "source": [
+    "## **SPOILER**"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "R1DOmoKKkawr"
+   },
+   "outputs": [],
+   "source": [
+    "gz_spoil = make_trapezoid(channel='z', system=system, area=gz90.area * 4,\n",
+    "                          duration=pre_time * 4)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "3F5JUpE9-4lo"
+   },
+   "source": [
+    "## **DELAYS**\n",
+    "Echo time (TE) and repetition time (TR). Here, TE is broken down into `delay1` and `delay2`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "aOKRJclb_mDQ"
+   },
+   "outputs": [],
+   "source": [
+    "delay1 = tau - calc_duration(rf90) / 2 - calc_duration(gx_pre)\n",
+    "delay1 -= calc_duration(gz_spoil) - calc_duration(rf180) / 2\n",
+    "delay1 = make_delay(delay1)\n",
+    "delay2 = tau - calc_duration(rf180) / 2 - calc_duration(gz_spoil)\n",
+    "delay2 -= calc_duration(gx) / 2\n",
+    "delay2 = make_delay(delay2)\n",
+    "delay_TR = TR - calc_duration(rf90) / 2 - calc_duration(gx) / 2 - TE\n",
+    "delay_TR -= calc_duration(gy_pre)\n",
+    "delay_TR = make_delay(delay_TR)\n",
+    "print(f'delay_1: {delay1}')\n",
+    "print(f'delay_2: {delay1}')\n",
+    "print(f'delay_TR: {delay_TR}')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "6Dq4wT-UAEOR"
+   },
+   "source": [
+    "## **CONSTRUCT SEQUENCE**\n",
+    "Construct sequence for one phase encode and multiple slices"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "B8ZmVkkrAXnK"
+   },
+   "outputs": [],
+   "source": [
+    "# Prepare RF offsets. This is required for multi-slice acquisition\n",
+    "delta_z = n_slices * slice_gap\n",
+    "z = np.linspace((-delta_z / 2), (delta_z / 2), n_slices) + rf_offset\n",
+    "\n",
+    "for k in range(nsa):  # Averages\n",
+    "  for j in range(n_slices):  # Slices\n",
+    "    # Apply RF offsets\n",
+    "    freq_offset = gz90.amplitude * z[j]\n",
+    "    rf90.freq_offset = freq_offset\n",
+    "\n",
+    "    freq_offset = gz180.amplitude * z[j]\n",
+    "    rf180.freq_offset = freq_offset\n",
+    "\n",
+    "    for i in range(Ny):  # Phase encodes\n",
+    "      seq.add_block(rf90, gz90)\n",
+    "      gy_pre = make_trapezoid(channel='y', system=system, \n",
+    "                              area=phase_areas[-i -1], duration=2e-3)\n",
+    "      seq.add_block(gx_pre, gy_pre, gz_reph)\n",
+    "      seq.add_block(delay1)\n",
+    "      seq.add_block(gz_spoil)\n",
+    "      seq.add_block(rf180, gz180)\n",
+    "      seq.add_block(gz_spoil)\n",
+    "      seq.add_block(delay2)\n",
+    "      seq.add_block(gx, adc)\n",
+    "      gy_pre = make_trapezoid(channel='y', system=system, \n",
+    "                              area=-phase_areas[-j -1], duration=2e-3)\n",
+    "      seq.add_block(gy_pre, gz_spoil)\n",
+    "      seq.add_block(delay_TR)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "l-YP9djBJCpC"
+   },
+   "source": [
+    "## **PLOTTING TIMNG DIAGRAM**"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "d_iCUR4nfoH9"
+   },
+   "outputs": [],
+   "source": [
+    "seq.plot(time_range=(0, 0.1))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "colab_type": "text",
+    "id": "fYNgdWc_KiK7"
+   },
+   "source": [
+    "## **GENERATING `.SEQ` FILE**\n",
+    "Uncomment the code in the cell below to generate a `.seq` file and download locally."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "6iN0aeuuqKRe"
+   },
+   "outputs": [],
+   "source": [
+    "# seq.write('t2_se_pypulseq_colab.seq')  # Save to disk\n",
+    "# from google.colab import files\n",
+    "# files.download('t2_se_pypulseq_colab.seq')  # Download locally"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 0,
+   "metadata": {
+    "colab": {},
+    "colab_type": "code",
+    "id": "4Q0b5w-lKtfP"
+   },
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "colab": {
+   "collapsed_sections": [],
+   "name": "write_t2_se.ipynb",
+   "private_outputs": true,
+   "provenance": []
+  },
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.6.3"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 1
+}

+ 43 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/README.md

@@ -0,0 +1,43 @@
+<p align="center">
+
+![PyPulseq](../../../logo.png)
+
+</p>
+
+# PyPulseq: A Python Package for MRI Pulse Sequence Design
+
+Example reconstructions of the Gradient Recalled Echo (GRE) and Turbo Spin Echo (TSE) sequences executed on a 
+Siemens Prisma 3T scanner:
+
+### 1. Gradient Recalled Echo (GRE)
+
+| Parameter | Value |
+|-----------|-------|
+| Field of view | 256 x 256 mm^-3 |
+| Nx | 256 |
+| Ny | 256 |
+| Flip angle | 10 |
+| Number of slices | 1 |
+| Slice thickness | 3 mm |
+| TE | 4.3 ms |
+| TR | 10 ms |
+| Number of echoes | 16 |
+
+![Gradient Recalled Echo](example_recons/gre.png)
+
+---
+
+### 2. Turbo Spin Echo (TSE)
+
+| Parameter | Value |
+|-----------|-------|
+| Field of view | 256 x 256 mm^-3 |
+| Nx | 128 |
+| Ny | 128 |
+| Flip angle | 10 |
+| Number of slices | 1 |
+| Slice thickness | 5 mm |
+| TE | 12 ms |
+| TR | 2000 ms |
+
+![Turbo Spin Echo](example_recons/tse.png)

+ 0 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/__init__.py


+ 100 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/demo_read.py

@@ -0,0 +1,100 @@
+import numpy as np
+from matplotlib import pyplot as plt
+
+import pypulseq as pp
+
+"""
+Read a sequence into MATLAB. The `Sequence` class provides an implementation of the _open file format_ for MR sequences 
+described here: http://pulseq.github.io/specification.pdf. This example demonstrates parsing an MRI sequence stored in 
+this format, accessing sequence parameters and visualising the sequence.
+"""
+
+# Read a sequence file - a sequence can be loaded from the open MR file format using the `read` method.
+seq_name = "epi_rs.seq"
+
+system = pp.Opts(
+    B0=2.89
+)  # Need system here if we want 'detectRFuse' to detect fat-sat pulses
+seq = pp.Sequence(system)
+seq.read(seq_name, detect_rf_use=True)
+
+# Sanity check to see if the reading and writing are consistent
+seq.write("read_test.seq")
+# os_system(f'diff -s -u {seq_name} read_test.seq -echo')  # Linux only
+
+"""
+Access sequence parameters and blocks. Parameters defined with in the `[DEFINITIONS]` section of the sequence file 
+are accessed with the `get_definition()` method. These are user-specified definitions and do not effect the execution of 
+the sequence.
+"""
+seq_name = seq.get_definition("Name")
+
+# Calculate and display real TE, TR as well as slew rates and gradient amplitudes
+test_report = seq.test_report()
+print(test_report)
+
+# Sequence blocks are accessed with the `get_block()` method. As shown in the output the first block is a selective
+# excitation block and contains an RF pulse and gradient and on the z-channel.
+b1 = seq.get_block(1)
+
+# Further information about each event can be obtained by accessing the appropriate fields of the block struct. In
+# particular, the complex RF signal is stored in the field `signal`.
+rf = b1.rf
+
+plt.subplot(211)
+plt.plot(rf.t, np.abs(rf.signal))
+plt.ylabel("RF magnitude")
+
+plt.subplot(212)
+plt.plot(1e3 * rf.t, np.angle(rf.signal))
+plt.xlabel("t (ms)")
+plt.ylabel("RF phase")
+
+# The next three blocks contain: three gradient events; a delay; and readout gradient with ADC event, each with
+# corresponding fields defining the details of the events.
+b2 = seq.get_block(2)
+b3 = seq.get_block(3)
+b4 = seq.get_block(4)
+
+# Plot the sequence. Visualise the sequence using the `plot()` method of the class. This creates a new figure and shows
+# ADC, RF and gradient events. The axes are linked so zooming is consistent. In this example, a simple gradient echo
+# sequence for MRI is displayed.
+# seq.plot()
+
+"""
+The details of individual pulses are not well-represented when the entire sequence is visualised. Interactive zooming 
+is helpful here. Alternatively, a time range can be specified. An additional parameter also allows the display units to 
+be changed for easy reading. Further, the handle of the created figure can be returned if required.
+"""
+# seq.plot(time_range=[0, 16e-3], time_disp='ms')
+
+"""
+Modifying sequence blocks. In addition to loading a sequence and accessing sequence blocks, blocks # can be modified. 
+In this example, a Hamming window is applied to the # first RF pulse of the sequence and the flip angle is changed to 
+45 degrees. The remaining RF pulses are unchanged.
+"""
+rf2 = rf
+duration = rf2.t[-1]
+t = rf2.t - duration / 2  # Centre time about 0
+alpha = 0.5
+BW = 4 / duration  # Time bandwidth product = 4
+window = 1.0 - alpha + alpha * np.cos(2 * np.pi * t / duration)  # Hamming window
+signal = window * np.sinc(BW * t)
+
+# Normalise area to achieve 2*pi rotation
+signal = signal / (seq.rf_raster_time * np.sum(np.real(signal)))
+
+# Scale to 45 degree flip angle
+rf2.signal = signal * 45 / 360
+
+b1.rf = rf2
+seq.set_block(1, b1)
+
+# Second check to see what has changed
+seq.write("read_test2.seq")
+# os_system(f'diff -s -u {seq_name} read_test2.seq -echo')  # Linux only
+
+# The amplitude of the first rf pulse is reduced due to the reduced flip-angle. Notice the reduction is not exactly a
+# factor of two due to the windowing function.
+amp1_in_Hz = max(abs(seq.get_block(1).rf.signal))
+amp2_in_Hz = max(abs(seq.get_block(6).rf.signal))

BIN
libs/lf-scanner/pypulseq/seq_examples/scripts/example_recons/gre.png


BIN
libs/lf-scanner/pypulseq/seq_examples/scripts/example_recons/tse.png


+ 129 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_2Dt1_mprage.py

@@ -0,0 +1,129 @@
+from math import pi
+
+import numpy as np
+
+import pypulseq as pp
+
+Nx = 128
+Ny = 128
+n_slices = 3
+
+system = pp.Opts(
+    max_grad=32,
+    grad_unit="mT/m",
+    max_slew=130,
+    slew_unit="T/m/s",
+    grad_raster_time=10e-6,
+    rf_ringdown_time=10e-6,
+    rf_dead_time=100e-6,
+)
+seq = pp.Sequence(system)
+
+fov = 220e-3
+slice_thickness = 5e-3
+slice_gap = 15e-3
+
+delta_z = n_slices * slice_gap
+rf_offset = 0
+z = np.linspace((-delta_z / 2), (delta_z / 2), n_slices) + rf_offset
+
+# =========
+# RF90, RF180
+# =========
+flip = 12 * pi / 180
+rf, gz, _ = pp.make_sinc_pulse(
+    flip_angle=flip,
+    system=system,
+    duration=2e-3,
+    slice_thickness=slice_thickness,
+    apodization=0.5,
+    time_bw_product=4,
+    return_gz=True,
+)
+
+flip90 = 90 * pi / 180
+rf90 = pp.make_block_pulse(
+    flip_angle=flip90, system=system, duration=500e-6, time_bw_product=4
+)
+
+# =========
+# Readout
+# =========
+delta_k = 1 / fov
+k_width = Nx * delta_k
+readout_time = 6.4e-3
+gx = pp.make_trapezoid(
+    channel="x", system=system, flat_area=k_width, flat_time=readout_time
+)
+adc = pp.make_adc(num_samples=Nx, duration=gx.flat_time, delay=gx.rise_time)
+
+# =========
+# Prephase and Rephase
+# =========
+phase_areas = (np.arange(Ny) - (Ny / 2)) * delta_k
+gy_pre = pp.make_trapezoid(
+    channel="y", system=system, area=phase_areas[-1], duration=2e-3
+)
+
+gx_pre = pp.make_trapezoid(channel="x", system=system, area=-gx.area / 2, duration=2e-3)
+
+gz_reph = pp.make_trapezoid(
+    channel="z", system=system, area=-gz.area / 2, duration=2e-3
+)
+
+# =========
+# Spoilers
+# =========
+pre_time = 8e-4
+gx_spoil = pp.make_trapezoid(
+    channel="x", system=system, area=gz.area * 4, duration=pre_time * 4
+)
+gy_spoil = pp.make_trapezoid(
+    channel="y", system=system, area=gz.area * 4, duration=pre_time * 4
+)
+gz_spoil = pp.make_trapezoid(
+    channel="z", system=system, area=gz.area * 4, duration=pre_time * 4
+)
+
+# =========
+# Delays
+# =========
+TE, TI, TR = 13e-3, 140e-3, 65e-3
+delay_TE = (
+    TE - pp.calc_duration(rf) / 2 - pp.calc_duration(gy_pre) - pp.calc_duration(gx) / 2
+)
+delay_TE = pp.make_delay(delay_TE)
+delay_TI = TI - pp.calc_duration(rf90) / 2 - pp.calc_duration(gx_spoil)
+delay_TI = pp.make_delay(delay_TI)
+delay_TR = (
+    TR
+    - pp.calc_duration(rf) / 2
+    - pp.calc_duration(gx) / 2
+    - pp.calc_duration(gy_pre)
+    - TE
+)
+delay_TR = pp.make_delay(delay_TR)
+
+for j in range(n_slices):
+    freq_offset = gz.amplitude * z[j]
+    rf.freq_offset = freq_offset
+
+    for i in range(Ny):
+        seq.add_block(rf90)
+        seq.add_block(gx_spoil, gy_spoil, gz_spoil)
+        seq.add_block(delay_TI)
+        seq.add_block(rf, gz)
+        gy_pre = pp.make_trapezoid(
+            channel="y", system=system, area=phase_areas[i], duration=2e-3
+        )
+        seq.add_block(gx_pre, gy_pre, gz_reph)
+        seq.add_block(delay_TE)
+        seq.add_block(gx, adc)
+        gy_pre = pp.make_trapezoid(
+            channel="y", system=system, area=-phase_areas[i], duration=2e-3
+        )
+        seq.add_block(gx_spoil, gy_pre)
+        seq.add_block(delay_TR)
+
+seq.set_definition(key="Name", value="2D T1 MPRAGE")
+seq.write("2d_mprage_pypulseq.seq")

+ 155 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_3Dt1_mprage.py

@@ -0,0 +1,155 @@
+from math import pi
+
+import numpy as np
+
+import pypulseq as pp
+
+Nx = 256
+Ny = 256
+Nz = 32
+
+system = pp.Opts(
+    max_grad=32,
+    grad_unit="mT/m",
+    max_slew=130,
+    slew_unit="T/m/s",
+    grad_raster_time=10e-6,
+    rf_ringdown_time=10e-6,
+    rf_dead_time=100e-6,
+)
+seq = pp.Sequence(system)
+
+fov = 256e-3
+fov_z = 256e-3
+slice_thickness = 1e-3
+section_thickness = 5e-3
+
+# =========
+# RF preparatory, excitation
+# =========
+flip_exc = 12 * pi / 180
+rf = pp.make_block_pulse(
+    flip_angle=flip_exc, system=system, duration=250e-6, time_bw_product=4
+)
+
+flip_prep = 90 * pi / 180
+rf_prep = pp.make_block_pulse(
+    flip_angle=flip_prep, system=system, duration=500e-6, time_bw_product=4
+)
+
+# =========
+# Readout
+# =========
+delta_k = 1 / fov
+k_width = Nx * delta_k
+readout_time = 3.5e-3
+gx = pp.make_trapezoid(
+    channel="x", system=system, flat_area=k_width, flat_time=readout_time
+)
+adc = pp.make_adc(num_samples=Nx, duration=gx.flat_time, delay=gx.rise_time)
+
+# =========
+# Prephase and Rephase
+# =========
+delta_kz = 1 / fov_z
+phase_areas = (np.arange(Ny) - (Ny / 2)) * delta_k
+slice_areas = (np.arange(Nz) - (Nz / 2)) * delta_kz
+
+gx_pre = pp.make_trapezoid(channel="x", system=system, area=-gx.area / 2, duration=2e-3)
+gy_pre = pp.make_trapezoid(
+    channel="y", system=system, area=phase_areas[-1], duration=2e-3
+)
+
+# =========
+# Spoilers
+# =========
+pre_time = 6.4e-4
+gx_spoil = pp.make_trapezoid(
+    channel="x",
+    system=system,
+    area=(4 * np.pi) / (42.576e6 * delta_k * 1e-3) * 42.576e6,
+    duration=pre_time * 6,
+)
+gy_spoil = pp.make_trapezoid(
+    channel="y",
+    system=system,
+    area=(4 * np.pi) / (42.576e6 * delta_k * 1e-3) * 42.576e6,
+    duration=pre_time * 6,
+)
+gz_spoil = pp.make_trapezoid(
+    channel="z",
+    system=system,
+    area=(4 * np.pi) / (42.576e6 * delta_kz * 1e-3) * 42.576e6,
+    duration=pre_time * 6,
+)
+
+# =========
+# Extended trapezoids: gx, gx_spoil
+# =========
+t_gx_extended = np.array(
+    [0, gx.rise_time, gx.flat_time, (gx.rise_time * 2) + gx.flat_time + gx.fall_time]
+)
+amp_gx_extended = np.array([0, gx.amplitude, gx.amplitude, gx_spoil.amplitude])
+t_gx_spoil_extended = np.array(
+    [
+        0,
+        gx_spoil.rise_time + gx_spoil.flat_time,
+        gx_spoil.rise_time + gx_spoil.flat_time + gx_spoil.fall_time,
+    ]
+)
+amp_gx_spoil_extended = np.array([gx_spoil.amplitude, gx_spoil.amplitude, 0])
+
+gx_extended = pp.make_extended_trapezoid(
+    channel="x", times=t_gx_extended, amplitudes=amp_gx_extended
+)
+gx_spoil_extended = pp.make_extended_trapezoid(
+    channel="x", times=t_gx_spoil_extended, amplitudes=amp_gx_spoil_extended
+)
+
+# =========
+# Delays
+# =========
+TE, TI, TR, T_recovery = 4e-3, 140e-3, 10e-3, 1e-3
+delay_TE = (
+    TE - pp.calc_duration(rf) / 2 - pp.calc_duration(gx_pre) - pp.calc_duration(gx) / 2
+)
+delay_TI = TI - pp.calc_duration(rf_prep) / 2 - pp.calc_duration(gx_spoil)
+delay_TR = (
+    TR
+    - pp.calc_duration(rf)
+    - pp.calc_duration(gx_pre)
+    - pp.calc_duration(gx)
+    - pp.calc_duration(gx_spoil)
+)
+
+for i in range(Ny):
+    gy_pre = pp.make_trapezoid(
+        channel="y", system=system, area=phase_areas[i], duration=2e-3
+    )
+
+    seq.add_block(rf_prep)
+    seq.add_block(gx_spoil, gy_spoil, gz_spoil)
+    seq.add_block(pp.make_delay(delay_TI))
+
+    for j in range(Nz):
+        gz_pre = pp.make_trapezoid(
+            channel="z", system=system, area=slice_areas[j], duration=2e-3
+        )
+        gz_reph = pp.make_trapezoid(
+            channel="z", system=system, area=-slice_areas[j], duration=2e-3
+        )
+
+        seq.add_block(rf)
+        seq.add_block(gx_pre, gy_pre, gz_pre)
+        # Skip TE: readout_time = 3.5e3 --> TE = -2.168404344971009e-19
+        # seq.add_block(pp.make_delay(delay_TE))
+        seq.add_block(gx_extended, adc)
+        seq.add_block(gx_spoil_extended, gz_reph)
+        seq.add_block(pp.make_delay(delay_TR))
+
+    seq.add_block(pp.make_delay(T_recovery))
+
+seq.set_definition(key="Name", value="3D T1 MPRAGE")
+
+seq.write("256_3d_t1_mprage_pypulseq.seq")
+# seq.plot(time_range=(0, TI + TR + 2e-3))

+ 196 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_MPRAGE.py

@@ -0,0 +1,196 @@
+from types import SimpleNamespace
+
+import numpy as np
+
+import pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "mprage_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    seq = pp.Sequence()  # Create a new sequence object
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=24,
+        grad_unit="mT/m",
+        max_slew=100,
+        slew_unit="T/m/s",
+        rf_ringdown_time=20e-6,
+        rf_dead_time=100e-6,
+        adc_dead_time=10e-6,
+    )
+
+    alpha = 7  # Flip angle
+    ro_dur = 5017.6e-6
+    ro_os = 1  # Readout oversampling
+    ro_spoil = 3  # Additional k-max excursion for RO spoiling
+    TI = 1.1
+    TR_out = 2.5
+
+    rf_spoiling_inc = 117
+    rf_len = 100e-6
+    ax = SimpleNamespace()  # Encoding axes
+
+    fov = np.array([192, 240, 256]) * 1e-3  # Define FOV and resolution
+    N = [192, 240, 256]
+    ax.d1 = "z"  # Fastest dimension (readout)
+    ax.d2 = "x"  # Second-fastest dimension (inner phase-encoding loop)
+    xyz = ["x", "y", "z"]
+    ax.d3 = np.setdiff1d(xyz, [ax.d1, ax.d2])[0]
+    ax.n1 = xyz.index(ax.d1)
+    ax.n2 = xyz.index(ax.d2)
+    ax.n3 = xyz.index(ax.d3)
+
+    # Create alpha-degree hard pulse and gradient
+    rf = pp.make_block_pulse(
+        flip_angle=alpha * np.pi / 180, system=system, duration=rf_len
+    )
+    rf180 = pp.make_adiabatic_pulse(
+        pulse_type="hypsec", system=system, duration=10.24e-3, dwell=1e-5
+    )
+
+    # Define other gradients and ADC events
+    deltak = 1 / fov
+    gro = pp.make_trapezoid(
+        channel=ax.d1,
+        amplitude=N[ax.n1] * deltak[ax.n1] / ro_dur,
+        flat_time=np.ceil((ro_dur + system.adc_dead_time) / system.grad_raster_time)
+        * system.grad_raster_time,
+        system=system,
+    )
+    adc = pp.make_adc(
+        num_samples=N[ax.n1] * ro_os,
+        duration=ro_dur,
+        delay=gro.rise_time,
+        system=system,
+    )
+    #  First 0.5 is necessary to account for the Siemens sampling in the center of the dwell periods
+    gro_pre = pp.make_trapezoid(
+        channel=ax.d1,
+        area=-gro.amplitude
+        * (adc.dwell * (adc.num_samples / 2 + 0.5) + 0.5 * gro.rise_time),
+        system=system,
+    )
+    gpe1 = pp.make_trapezoid(
+        channel=ax.d2, area=-deltak[ax.n2] * (N[ax.n2] / 2), system=system
+    )  # Maximum PE1 gradient
+    gpe2 = pp.make_trapezoid(
+        channel=ax.d3, area=-deltak[ax.n3] * (N[ax.n3] / 2), system=system
+    )  # Maximum PE2 gradient
+    # Spoil with 4x cycles per voxel
+    gsl_sp = pp.make_trapezoid(
+        channel=ax.d3, area=np.max(deltak * N) * 4, duration=10e-3, system=system
+    )
+
+    # We cut the RO gradient into two parts for the optimal spoiler timing
+    gro1, gro_Sp = pp.split_gradient_at(
+        grad=gro, time_point=gro.rise_time + gro.flat_time
+    )
+    # Gradient spoiling
+    if ro_spoil > 0:
+        gro_Sp = pp.make_extended_trapezoid_area(
+            channel=gro.channel,
+            grad_start=gro.amplitude,
+            grad_end=0,
+            area=deltak[ax.n1] / 2 * N[ax.n1] * ro_spoil,
+            system=system,
+        )[0]
+
+    # Calculate timing of the fast loop. We will have two blocks in the inner loop:
+    # 1: spoilers/rewinders + RF
+    # 2: prewinder, phase enconding + readout
+    rf.delay = pp.calc_duration(gro_Sp, gpe1, gpe2)
+    gro_pre, _, _ = pp.align(right=[gro_pre, gpe1, gpe2])
+    gro1.delay = pp.calc_duration(gro_pre)
+    adc.delay = gro1.delay + gro.rise_time
+    gro1 = pp.add_gradients(grads=[gro1, gro_pre], system=system)
+    TR_inner = pp.calc_duration(rf) + pp.calc_duration(gro1)  # For TI delay
+    # pe_steps -- control reordering
+    pe1_steps = ((np.arange(N[ax.n2])) - N[ax.n2] / 2) / N[ax.n2] * 2
+    pe2_steps = ((np.arange(N[ax.n3])) - N[ax.n3] / 2) / N[ax.n3] * 2
+    # TI calc
+    TI_delay = (
+        np.round(
+            (
+                TI
+                - (np.where(pe1_steps == 0)[0][0]) * TR_inner
+                - (pp.calc_duration(rf180) - pp.calc_rf_center(rf180)[0] - rf180.delay)
+                - rf.delay
+                - pp.calc_rf_center(rf)[0]
+            )
+            / system.block_duration_raster
+        )
+        * system.block_duration_raster
+    )
+    TR_out_delay = TR_out - TR_inner * N[ax.n2] - TI_delay - pp.calc_duration(rf180)
+
+    # All LABELS / counters an flags are automatically initialized to 0 in the beginning, no need to define initial 0's
+    # so we will just increment LIN after the ADC event (e.g. during the spoiler)
+    label_inc_lin = pp.make_label(type="INC", label="LIN", value=1)
+    label_inc_par = pp.make_label(type="INC", label="PAR", value=1)
+    label_reset_par = pp.make_label(type="SET", label="PAR", value=0)
+
+    # Pre-register objects that do not change while looping
+    result = seq.register_grad_event(gsl_sp)
+    gsl_sp.id = result if isinstance(result, int) else result[0]
+    result = seq.register_grad_event(gro_Sp)
+    gro_Sp.id = result if isinstance(result, int) else result[0]
+    result = seq.register_grad_event(gro1)
+    gro1.id = result if isinstance(result, int) else result[0]
+    # Phase of the RF object will change, therefore we only pre-register the shapes
+    _, rf.shape_IDs = seq.register_rf_event(rf)
+    rf180.id, rf180.shape_IDs = seq.register_rf_event(rf180)
+    label_inc_par.id = seq.register_label_event(label_inc_par)
+
+    # Sequence
+    for j in range(N[ax.n3]):
+        seq.add_block(rf180)
+        seq.add_block(pp.make_delay(TI_delay), gsl_sp)
+        rf_phase = 0
+        rf_inc = 0
+        # Pre-register PE events that repeat in the inner loop
+        gpe2je = pp.scale_grad(grad=gpe2, scale=pe2_steps[j])
+        gpe2je.id = seq.register_grad_event(gpe2je)
+        gpe2jr = pp.scale_grad(grad=gpe2, scale=-pe2_steps[j])
+        gpe2jr.id = seq.register_grad_event(gpe2jr)
+
+        for i in range(N[ax.n2]):
+            rf.phase_offset = rf_phase / 180 * np.pi
+            adc.phase_offset = rf_phase / 180 * np.pi
+            rf_inc = np.mod(rf_inc + rf_spoiling_inc, 360.0)
+            rf_phase = np.mod(rf_phase + rf_inc, 360.0)
+
+            if i == 0:
+                seq.add_block(rf)
+            else:
+                seq.add_block(
+                    rf,
+                    gro_Sp,
+                    pp.scale_grad(grad=gpe1, scale=-pe1_steps[i - 1]),
+                    gpe2jr,
+                    label_inc_par,
+                )
+            seq.add_block(
+                adc, gro1, pp.scale_grad(grad=gpe1, scale=pe1_steps[i]), gpe2je
+            )
+        seq.add_block(
+            gro_Sp, pp.make_delay(TR_out_delay), label_reset_par, label_inc_lin
+        )
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot(time_range=[0, TR_out * 2], label="PAR")
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 114 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_epi.py

@@ -0,0 +1,114 @@
+"""
+Demo low-performance EPI sequence without ramp-sampling.
+"""
+
+import numpy as np
+
+import MRI_seq.pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "epi_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    seq = pp.Sequence()  # Create a new sequence object
+    # Define FOV and resolution
+    fov = 220e-3
+    Nx = 64
+    Ny = 64
+    slice_thickness = 3e-3  # Slice thickness
+    n_slices = 3
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=32,
+        grad_unit="mT/m",
+        max_slew=130,
+        slew_unit="T/m/s",
+        rf_ringdown_time=30e-6,
+        rf_dead_time=100e-6,
+    )
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    # Create 90 degree slice selection pulse and gradient
+    rf, gz, _ = pp.make_sinc_pulse(
+        flip_angle=np.pi / 2,
+        system=system,
+        duration=3e-3,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        return_gz=True,
+    )
+
+    # Define other gradients and ADC events
+    delta_k = 1 / fov
+    k_width = Nx * delta_k
+    dwell_time = 4e-6
+    readout_time = Nx * dwell_time
+    flat_time = np.ceil(readout_time * 1e5) * 1e-5  # round-up to the gradient raster
+    gx = pp.make_trapezoid(
+        channel="x",
+        system=system,
+        amplitude=k_width / readout_time,
+        flat_time=flat_time,
+    )
+    adc = pp.make_adc(
+        num_samples=Nx,
+        duration=readout_time,
+        delay=gx.rise_time + flat_time / 2 - (readout_time - dwell_time) / 2,
+    )
+
+    # Pre-phasing gradients
+    pre_time = 8e-4
+    gx_pre = pp.make_trapezoid(
+        channel="x", system=system, area=-gx.area / 2, duration=pre_time
+    )
+    gz_reph = pp.make_trapezoid(
+        channel="z", system=system, area=-gz.area / 2, duration=pre_time
+    )
+    gy_pre = pp.make_trapezoid(
+        channel="y", system=system, area=-Ny / 2 * delta_k, duration=pre_time
+    )
+
+    # Phase blip in the shortest possible time
+    dur = np.ceil(2 * np.sqrt(delta_k / system.max_slew) / 10e-6) * 10e-6
+    gy = pp.make_trapezoid(channel="y", system=system, area=delta_k, duration=dur)
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    # Define sequence blocks
+    for s in range(n_slices):
+        rf.freq_offset = gz.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+        seq.add_block(rf, gz)
+        seq.add_block(gx_pre, gy_pre, gz_reph)
+        for i in range(Ny):
+            seq.add_block(gx, adc)  # Read one line of k-space
+            seq.add_block(gy)  # Phase blip
+            gx.amplitude = -gx.amplitude  # Reverse polarity of read gradient
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed! Error listing follows:")
+        print(error_report)
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()  # Plot sequence waveforms
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 174 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_epi_label.py

@@ -0,0 +1,174 @@
+"""
+Demo low-performance EPI sequence without ramp-sampling.
+In addition, it demonstrates how the LABEL extension can be used to set data header values, which can be used either in
+combination with integrated image reconstruction or to guide the off-line reconstruction tools.
+"""
+
+import numpy as np
+
+import MRI_seq.pypulseq as pp
+from MRI_seq.pypulseq import calc_rf_center
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "epi_lable_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    seq = pp.Sequence()  # Create a new sequence object
+    fov = 220e-3  # Define FOV and resolution
+    Nx = 96
+    Ny = 96
+    slice_thickness = 3e-3  # Slice thickness
+    n_slices = 7
+    n_reps = 4
+    navigator = 3
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=32,
+        grad_unit="mT/m",
+        max_slew=130,
+        slew_unit="T/m/s",
+        rf_ringdown_time=30e-6,
+        rf_dead_time=100e-6,
+    )
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    # Create 90 degree slice selection pulse and gradient
+    rf, gz, _ = pp.make_sinc_pulse(
+        flip_angle=np.pi / 2,
+        system=system,
+        duration=3e-3,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        return_gz=True,
+    )
+
+    # Define trigger
+    trig = pp.make_trigger(channel="physio1", duration=2000e-6)
+
+    # Define other gradients and ADC events
+    delta_k = 1 / fov
+    k_width = Nx * delta_k
+    dwell_time = 4e-6
+    readout_time = Nx * dwell_time
+    flat_time = np.ceil(readout_time * 1e5) * 1e-5  # Round-up to the gradient raster
+    gx = pp.make_trapezoid(
+        channel="x",
+        system=system,
+        amplitude=k_width / readout_time,
+        flat_time=flat_time,
+    )
+    adc = pp.make_adc(
+        num_samples=Nx,
+        duration=readout_time,
+        delay=gx.rise_time + flat_time / 2 - (readout_time - dwell_time) / 2,
+    )
+
+    # Pre-phasing gradients
+    pre_time = 8e-4
+    gx_pre = pp.make_trapezoid(
+        channel="x", system=system, area=-gx.area / 2, duration=pre_time
+    )
+    gz_reph = pp.make_trapezoid(
+        channel="z", system=system, area=-gz.area / 2, duration=pre_time
+    )
+    gy_pre = pp.make_trapezoid(
+        channel="y", system=system, area=Ny / 2 * delta_k, duration=pre_time
+    )
+
+    # Phase blip in the shortest possible time
+    dur = np.ceil(2 * np.sqrt(delta_k / system.max_slew) / 10e-6) * 10e-6
+    gy = pp.make_trapezoid(channel="y", system=system, area=-delta_k, duration=dur)
+
+    gz_spoil = pp.make_trapezoid(channel="z", system=system, area=delta_k * Nx * 4)
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    # Define sequence blocks
+    for r in range(n_reps):
+        seq.add_block(trig, pp.make_label(type="SET", label="SLC", value=0))
+        for s in range(n_slices):
+            rf.freq_offset = gz.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+            # Compensate for the slide-offset induced phase
+            rf.phase_offset = -rf.freq_offset * calc_rf_center(rf)[0]
+            seq.add_block(rf, gz)
+            seq.add_block(
+                gx_pre,
+                gz_reph,
+                pp.make_label(type="SET", label="NAV", value=1),
+                pp.make_label(type="SET", label="LIN", value=np.round(Ny / 2)),
+            )
+            for n in range(navigator):
+                seq.add_block(
+                    gx,
+                    adc,
+                    pp.make_label(type="SET", label="REV", value=gx.amplitude < 0),
+                    pp.make_label(type="SET", label="SEG", value=gx.amplitude < 0),
+                    pp.make_label(type="SET", label="AVG", value=n + 1 == 3),
+                )
+                if n + 1 != navigator:
+                    # Dummy blip pulse to maintain identical RO gradient timing and the corresponding eddy currents
+                    seq.add_block(pp.make_delay(pp.calc_duration(gy)))
+
+                gx.amplitude = -gx.amplitude  # Reverse polarity of read gradient
+
+            # Reset lin/nav/avg
+            seq.add_block(
+                gy_pre,
+                pp.make_label(type="SET", label="LIN", value=0),
+                pp.make_label(type="SET", label="NAV", value=0),
+                pp.make_label(type="SET", label="AVG", value=0),
+            )
+
+            for i in range(Ny):
+                seq.add_block(
+                    pp.make_label(type="SET", label="REV", value=gx.amplitude < 0),
+                    pp.make_label(type="SET", label="SEG", value=gx.amplitude < 0),
+                )
+                seq.add_block(gx, adc)  # Read one line of k-space
+                # Phase blip
+                seq.add_block(gy, pp.make_label(type="INC", label="LIN", value=1))
+                gx.amplitude = -gx.amplitude  # Reverse polarity of read gradient
+
+            seq.add_block(
+                gz_spoil,
+                pp.make_delay(0.1),
+                pp.make_label(type="INC", label="SLC", value=1),
+            )
+            if np.remainder(navigator + Ny, 2) != 0:
+                gx.amplitude = -gx.amplitude
+
+        seq.add_block(pp.make_label(type="INC", label="REP", value=1))
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed! Error listing follows:")
+        print(error_report)
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot(
+            time_range=(0, 0.1), time_disp="ms", label="SEG, LIN, SLC"
+        )  # Plot sequence waveforms
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        # Prepare sequence report
+        seq.set_definition(key="FOV", value=[fov, fov, slice_thickness * n_slices])
+        seq.set_definition(key="Name", value="epi_lbl")
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 139 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_epi_se.py

@@ -0,0 +1,139 @@
+import math
+
+import numpy as np
+
+import MRI_seq.pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "epi_se_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    seq = pp.Sequence()  # Create a new sequence object
+    fov = 256e-3  # Define FOV and resolution
+    Nx = 64
+    Ny = 64
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=32,
+        grad_unit="mT/m",
+        max_slew=130,
+        slew_unit="T/m/s",
+        rf_ringdown_time=20e-6,
+        rf_dead_time=100e-6,
+        adc_dead_time=20e-6,
+    )
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    # Create 90 degree slice selection pulse and gradient
+    rf, gz, _ = pp.make_sinc_pulse(
+        flip_angle=np.pi / 2,
+        system=system,
+        duration=3e-3,
+        slice_thickness=3e-3,
+        apodization=0.5,
+        time_bw_product=4,
+        return_gz=True,
+    )
+
+    # Define other gradients and ADC events
+    delta_k = 1 / fov
+    k_width = Nx * delta_k
+    readout_time = 3.2e-4
+    gx = pp.make_trapezoid(
+        channel="x", system=system, flat_area=k_width, flat_time=readout_time
+    )
+    adc = pp.make_adc(
+        num_samples=Nx, system=system, duration=gx.flat_time, delay=gx.rise_time
+    )
+
+    # Pre-phasing gradients
+    pre_time = 8e-4
+    gz_reph = pp.make_trapezoid(
+        channel="z", system=system, area=-gz.area / 2, duration=pre_time
+    )
+    # Do not need minus for in-plane prephasers because of the spin-echo (position reflection in k-space)
+    gx_pre = pp.make_trapezoid(
+        channel="x", system=system, area=gx.area / 2 - delta_k / 2, duration=pre_time
+    )
+    gy_pre = pp.make_trapezoid(
+        channel="y", system=system, area=Ny / 2 * delta_k, duration=pre_time
+    )
+
+    # Phase blip in shortest possible time
+    dur = math.ceil(2 * math.sqrt(delta_k / system.max_slew) / 10e-6) * 10e-6
+    gy = pp.make_trapezoid(channel="y", system=system, area=delta_k, duration=dur)
+
+    # Refocusing pulse with spoiling gradients
+    rf180 = pp.make_block_pulse(
+        flip_angle=np.pi, system=system, duration=500e-6, use="refocusing"
+    )
+    gz_spoil = pp.make_trapezoid(
+        channel="z", system=system, area=gz.area * 2, duration=3 * pre_time
+    )
+
+    # Calculate delay time
+    TE = 60e-3
+    duration_to_center = (Nx / 2 + 0.5) * pp.calc_duration(
+        gx
+    ) + Ny / 2 * pp.calc_duration(gy)
+    rf_center_incl_delay = rf.delay + pp.calc_rf_center(rf)[0]
+    rf180_center_incl_delay = rf180.delay + pp.calc_rf_center(rf180)[0]
+    delay_TE1 = (
+        TE / 2
+        - pp.calc_duration(gz)
+        + rf_center_incl_delay
+        - pre_time
+        - pp.calc_duration(gz_spoil)
+        - rf180_center_incl_delay
+    )
+    delay_TE2 = (
+        TE / 2
+        - pp.calc_duration(rf180)
+        + rf180_center_incl_delay
+        - pp.calc_duration(gz_spoil)
+        - duration_to_center
+    )
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    # Define sequence blocks
+    seq.add_block(rf, gz)
+    seq.add_block(gx_pre, gy_pre, gz_reph)
+    seq.add_block(pp.make_delay(delay_TE1))
+    seq.add_block(gz_spoil)
+    seq.add_block(rf180)
+    seq.add_block(gz_spoil)
+    seq.add_block(pp.make_delay(delay_TE2))
+    for i in range(Ny):
+        seq.add_block(gx, adc)  # Read one line of k-space
+        seq.add_block(gy)  # Phase blip
+        gx.amplitude = -gx.amplitude  # Reverse polarity of read gradient
+    seq.add_block(pp.make_delay(1e-4))
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed! Error listing follows:")
+        print(error_report)
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 287 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_epi_se_rs.py

@@ -0,0 +1,287 @@
+"""
+This is an experimental high-performance EPI sequence which uses split gradients to overlap blips with the readout 
+gradients combined with ramp-sampling.
+"""
+import math
+
+import numpy as np
+
+import MRI_seq.pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "epi_se_rs_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    seq = pp.Sequence()  # Create a new sequence object
+    fov = 250e-3  # Define FOV and resolution
+    Nx = 64
+    Ny = 64
+    slice_thickness = 3e-3  # Slice thickness
+    n_slices = 3
+    TE = 40e-3
+
+    pe_enable = 1  # Flag to quickly disable phase encoding (1/0) as needed for the delay calibration
+    ro_os = 1  # Oversampling factor
+    readout_time = 4.2e-4  # Readout bandwidth
+    # Partial Fourier factor: 1: full sampling; 0: start with ky=0
+    part_fourier_factor = 0.75
+
+    t_RF_ex = 2e-3
+    t_RF_ref = 2e-3
+    spoil_factor = 1.5  # Spoiling gradient around the pi-pulse (rf180)
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=32,
+        grad_unit="mT/m",
+        max_slew=130,
+        slew_unit="T/m/s",
+        rf_ringdown_time=30e-6,
+        rf_dead_time=100e-6,
+    )
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    # Create fat-sat pulse
+    B0 = 2.89
+    sat_ppm = -3.45
+    sat_freq = sat_ppm * 1e-6 * B0 * system.gamma
+    rf_fs = pp.make_gauss_pulse(
+        flip_angle=110 * np.pi / 180,
+        system=system,
+        duration=8e-3,
+        bandwidth=np.abs(sat_freq),
+        freq_offset=sat_freq,
+    )
+    gz_fs = pp.make_trapezoid(
+        channel="z", system=system, delay=pp.calc_duration(rf_fs), area=1 / 1e-4
+    )
+
+    # Create 90 degree slice selection pulse and gradient
+    rf, gz, gz_reph = pp.make_sinc_pulse(
+        flip_angle=np.pi / 2,
+        system=system,
+        duration=t_RF_ex,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        return_gz=True,
+    )
+
+    # Create 90 degree slice refocusing pulse and gradients
+    rf180, gz180, _ = pp.make_sinc_pulse(
+        flip_angle=np.pi,
+        system=system,
+        duration=t_RF_ref,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        phase_offset=np.pi / 2,
+        use="refocusing",
+        return_gz=True,
+    )
+    _, gzr1_t, gzr1_a = pp.make_extended_trapezoid_area(
+        channel="z",
+        grad_start=0,
+        grad_end=gz180.amplitude,
+        area=spoil_factor * gz.area,
+        system=system,
+    )
+    _, gzr2_t, gzr2_a = pp.make_extended_trapezoid_area(
+        channel="z",
+        grad_start=gz180.amplitude,
+        grad_end=0,
+        area=-gz_reph.area + spoil_factor * gz.area,
+        system=system,
+    )
+    if gz180.delay > (gzr1_t[3] - gz180.rise_time):
+        gz180.delay -= gzr1_t[3] - gz180.rise_time
+    else:
+        rf180.delay += (gzr1_t[3] - gz180.rise_time) - gz180.delay
+    gz180n = pp.make_extended_trapezoid(
+        channel="z",
+        system=system,
+        times=np.array([*gzr1_t, *gzr1_t[3] + gz180.flat_time + gzr2_t]) + gz180.delay,
+        amplitudes=np.array([*gzr1_a, *gzr2_a]),
+    )
+
+    # Define the output trigger to play out with every slice excitation
+    trig = pp.make_digital_output_pulse(channel="osc0", duration=100e-6)
+
+    # Define other gradients and ADC events
+    delta_k = 1 / fov
+    k_width = Nx * delta_k
+
+    # Phase blip in shortest possible time
+    # Round up the duration to 2x gradient raster time
+    blip_duration = (
+        np.ceil(2 * np.sqrt(delta_k / system.max_slew) / 10e-6 / 2) * 10e-6 * 2
+    )
+    # Use negative blips to save one k-space line on our way to center of k-space
+    gy = pp.make_trapezoid(
+        channel="y", system=system, area=-delta_k, duration=blip_duration
+    )
+
+    # Readout gradient is a truncated trapezoid with dead times at the beginning and at the end each equal to a half of
+    # blip duration. The area between the blips should be defined by k_width. We do a two-step calculation: we first
+    # increase the area assuming maximum slew rate and then scale down the amplitude to fix the area
+    extra_area = blip_duration / 2 * blip_duration / 2 * system.max_slew
+    gx = pp.make_trapezoid(
+        channel="x",
+        system=system,
+        area=k_width + extra_area,
+        duration=readout_time + blip_duration,
+    )
+    actual_area = (
+        gx.area
+        - gx.amplitude / gx.rise_time * blip_duration / 2 * blip_duration / 2 / 2
+    )
+    actual_area -= (
+        gx.amplitude / gx.fall_time * blip_duration / 2 * blip_duration / 2 / 2
+    )
+    gx.amplitude = gx.amplitude / actual_area * k_width
+    gx.area = gx.amplitude * (gx.flat_time + gx.rise_time / 2 + gx.fall_time / 2)
+    gx.flat_area = gx.amplitude * gx.flat_time
+
+    # Calculate ADC
+    # We use ramp sampling, so we have to calculate the dwell time and the number of samples, which will be quite
+    # different from Nx and readout_time/Nx, respectively.
+    adc_dwell_nyquist = delta_k / gx.amplitude / ro_os
+    # Round-down dwell time to 100 ns
+    adc_dwell = math.floor(adc_dwell_nyquist * 1e7) * 1e-7
+    # Number of samples on Siemens needs to be divisible by 4
+    adc_samples = math.floor(readout_time / adc_dwell / 4) * 4
+    adc = pp.make_adc(num_samples=adc_samples, dwell=adc_dwell, delay=blip_duration / 2)
+    # Realign the ADC with respect to the gradient
+    # Supposedly Siemens samples at center of dwell period
+    time_to_center = adc_dwell * ((adc_samples - 1) / 2 + 0.5)
+    # Adjust delay to align the trajectory with the gradient. We have to align the delay to 1us
+    adc.delay = round((gx.rise_time + gx.flat_time / 2 - time_to_center) * 1e6) * 1e-6
+    # This rounding actually makes the sampling points on odd and even readouts to appear misaligned. However, on the
+    # real hardware this misalignment is much stronger anyways due to the gradient delays
+
+    # Split the blip into two halves and produce a combined synthetic gradient
+    gy_parts = pp.split_gradient_at(
+        grad=gy, time_point=blip_duration / 2, system=system
+    )
+    gy_blipup, gy_blipdown, _ = pp.align(right=gy_parts[0], left=[gy_parts[1], gx])
+    gy_blipdownup = pp.add_gradients((gy_blipdown, gy_blipup), system=system)
+
+    # pe_enable support
+    gy_blipup.waveform = gy_blipup.waveform * pe_enable
+    gy_blipdown.waveform = gy_blipdown.waveform * pe_enable
+    gy_blipdownup.waveform = gy_blipdownup.waveform * pe_enable
+
+    # Phase encoding and partial Fourier
+    # PE steps prior to ky=0, excluding the central line
+    Ny_pre = round(part_fourier_factor * Ny / 2 - 1)
+    # PE lines after the k-space center including the central line
+    Ny_post = round(Ny / 2 + 1)
+    Ny_meas = Ny_pre + Ny_post
+
+    # Pre-phasing gradients
+    gx_pre = pp.make_trapezoid(channel="x", system=system, area=-gx.area / 2)
+    gy_pre = pp.make_trapezoid(channel="y", system=system, area=Ny_pre * delta_k)
+
+    gx_pre, gy_pre = pp.align(right=gx_pre, left=gy_pre)
+    # Relax the PE prephaser to reduce stimulation
+    gy_pre = pp.make_trapezoid(
+        "y", system=system, area=gy_pre.area, duration=pp.calc_duration(gx_pre, gy_pre)
+    )
+    gy_pre.amplitude = gy_pre.amplitude * pe_enable
+
+    # Calculate delay times
+    duration_to_center = (Ny_pre + 0.5) * pp.calc_duration(gx)
+    rf_center_incl_delay = rf.delay + pp.calc_rf_center(rf)[0]
+    rf180_center_incl_delay = rf180.delay + pp.calc_rf_center(rf180)[0]
+    delay_TE1 = (
+        math.ceil(
+            (
+                TE / 2
+                - pp.calc_duration(rf, gz)
+                + rf_center_incl_delay
+                - rf180_center_incl_delay
+            )
+            / system.grad_raster_time
+        )
+        * system.grad_raster_time
+    )
+    delay_TE2 = (
+        math.ceil(
+            (
+                TE / 2
+                - pp.calc_duration(rf180, gz180n)
+                + rf180_center_incl_delay
+                - duration_to_center
+            )
+            / system.grad_raster_time
+        )
+        * system.grad_raster_time
+    )
+    assert delay_TE1 >= 0
+    # Now we merge slice refocusing, TE delay and pre-phasers into a single block
+    delay_TE2 = delay_TE2 + pp.calc_duration(rf180, gz180n)
+    gx_pre.delay = 0
+    gx_pre.delay = delay_TE2 - pp.calc_duration(gx_pre)
+    assert gx_pre.delay >= pp.calc_duration(rf180)  # gx_pre may not overlap with the RF
+    gy_pre.delay = pp.calc_duration(rf180)
+    # gy_pre may not shift the timing
+    assert pp.calc_duration(gy_pre) <= pp.calc_duration(gx_pre)
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    # Define sequence blocks
+    for s in range(n_slices):
+        seq.add_block(rf_fs, gz_fs)
+        rf.freq_offset = gz.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+        rf180.freq_offset = gz180.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+        seq.add_block(rf, gz, trig)
+        seq.add_block(pp.make_delay(delay_TE1))
+        seq.add_block(rf180, gz180n, pp.make_delay(delay_TE2), gx_pre, gy_pre)
+        for i in range(1, Ny_meas + 1):
+            if i == 1:
+                # Read the first line of k-space with a single half-blip at the end
+                seq.add_block(gx, gy_blipup, adc)
+            elif i == Ny_meas:
+                # Read the last line of k-space with a single half-blip at the beginning
+                seq.add_block(gx, gy_blipdown, adc)
+            else:
+                # Read an intermediate line of k-space with a half-blip at the beginning and a half-blip at the end
+                seq.add_block(gx, gy_blipdownup, adc)
+            gx.amplitude = -gx.amplitude  # Reverse polarity of read gradient
+
+    # Check whether the timing of the sequence is correct
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # Very optional slow step, but useful for testing during development e.g. for the real TE, TR or for staying within
+    # slew-rate limits
+    rep = seq.test_report()
+    print(rep)
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        # Prepare the sequence output for the scanner
+        seq.set_definition(key="FOV", value=[fov, fov, slice_thickness])
+        seq.set_definition(key="Name", value="epi")
+
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 158 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_gre.py

@@ -0,0 +1,158 @@
+import math
+
+import numpy as np
+
+import pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "gre_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    # Create a new sequence object
+    seq = pp.Sequence()
+    fov = 256e-3  # Define FOV and resolution
+    Nx = 256
+    Ny = 256
+    alpha = 10  # flip angle
+    slice_thickness = 3e-3  # slice
+    TR = 12e-3  # Repetition time
+    TE = 5e-3  # Echo time
+
+    rf_spoiling_inc = 117  # RF spoiling increment
+
+    system = pp.Opts(
+        max_grad=28,
+        grad_unit="mT/m",
+        max_slew=150,
+        slew_unit="T/m/s",
+        rf_ringdown_time=20e-6,
+        rf_dead_time=100e-6,
+        adc_dead_time=10e-6,
+    )
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    rf, gz, _ = pp.make_sinc_pulse(
+        flip_angle=alpha * math.pi / 180,
+        duration=3e-3,
+        slice_thickness=slice_thickness,
+        apodization=0.42,
+        time_bw_product=4,
+        system=system,
+        return_gz=True,
+    )
+    # Define other gradients and ADC events
+    delta_k = 1 / fov
+    gx = pp.make_trapezoid(
+        channel="x", flat_area=Nx * delta_k, flat_time=3.2e-3, system=system
+    )
+    adc = pp.make_adc(
+        num_samples=Nx, duration=gx.flat_time, delay=gx.rise_time, system=system
+    )
+    gx_pre = pp.make_trapezoid(
+        channel="x", area=-gx.area / 2, duration=1e-3, system=system
+    )
+    gz_reph = pp.make_trapezoid(
+        channel="z", area=-gz.area / 2, duration=1e-3, system=system
+    )
+    phase_areas = (np.arange(Ny) - Ny / 2) * delta_k
+
+    # gradient spoiling
+    gx_spoil = pp.make_trapezoid(channel="x", area=2 * Nx * delta_k, system=system)
+    gz_spoil = pp.make_trapezoid(channel="z", area=4 / slice_thickness, system=system)
+
+    # Calculate timing
+    delay_TE = (
+        np.ceil(
+            (
+                TE
+                - pp.calc_duration(gx_pre)
+                - gz.fall_time
+                - gz.flat_time / 2
+                - pp.calc_duration(gx) / 2
+            )
+            / seq.grad_raster_time
+        )
+        * seq.grad_raster_time
+    )
+    delay_TR = (
+        np.ceil(
+            (
+                TR
+                - pp.calc_duration(gz)
+                - pp.calc_duration(gx_pre)
+                - pp.calc_duration(gx)
+                - delay_TE
+            )
+            / seq.grad_raster_time
+        )
+        * seq.grad_raster_time
+    )
+
+    assert np.all(delay_TE >= 0)
+    assert np.all(delay_TR >= pp.calc_duration(gx_spoil, gz_spoil))
+
+    rf_phase = 0
+    rf_inc = 0
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    # Loop over phase encodes and define sequence blocks
+    for i in range(Ny):
+        rf.phase_offset = rf_phase / 180 * np.pi
+        adc.phase_offset = rf_phase / 180 * np.pi
+        rf_inc = divmod(rf_inc + rf_spoiling_inc, 360.0)[1]
+        rf_phase = divmod(rf_phase + rf_inc, 360.0)[1]
+
+        seq.add_block(rf, gz)
+        gy_pre = pp.make_trapezoid(
+            channel="y",
+            area=phase_areas[i],
+            duration=pp.calc_duration(gx_pre),
+            system=system,
+        )
+        seq.add_block(gx_pre, gy_pre, gz_reph)
+        seq.add_block(pp.make_delay(delay_TE))
+        seq.add_block(gx, adc)
+        gy_pre.amplitude = -gy_pre.amplitude
+        seq.add_block(pp.make_delay(delay_TR), gx_spoil, gy_pre, gz_spoil)
+
+    # Check whether the timing of the sequence is correct
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    seq.calculate_kspacePP()
+
+    # Very optional slow step, but useful for testing during development e.g. for the real TE, TR or for staying within
+    # slew-rate limits
+    rep = seq.test_report()
+    print(rep)
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        # Prepare the sequence output for the scanner
+        seq.set_definition(key="FOV", value=[fov, fov, slice_thickness])
+        seq.set_definition(key="Name", value="gre")
+
+        seq.write(seq_filename)
+
+    from py2jemris.seq2xml import seq2xml
+    seq2xml(seq, seq_name='t1_TSE_matrx16x16', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 169 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_gre_label.py

@@ -0,0 +1,169 @@
+import math
+
+import numpy as np
+
+import pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "gre_label_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    seq = pp.Sequence()  # Create a new sequence object
+    fov = 224e-3  # Define FOV and resolution
+    Nx = 256
+    Ny = Nx
+    alpha = 7  # Flip angle
+    slice_thickness = 3e-3  # Slice thickness
+    n_slices = 1
+    TE = 4.3e-3  # Echo time
+    TR = 10e-3  # Repetition time
+
+    rf_spoiling_inc = 117  # RF spoiling increment
+    ro_duration = 3.2e-3  # ADC duration
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=28,
+        grad_unit="mT/m",
+        max_slew=150,
+        slew_unit="T/m/s",
+        rf_ringdown_time=20e-6,
+        rf_dead_time=100e-6,
+        adc_dead_time=10e-6,
+    )
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    # Create alpha-degree slice selection pulse and gradient
+    rf, gz, _ = pp.make_sinc_pulse(
+        flip_angle=alpha * np.pi / 180,
+        duration=3e-3,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        system=system,
+        return_gz=True,
+    )
+
+    # Define other gradients and ADC events
+    delta_k = 1 / fov
+    gx = pp.make_trapezoid(
+        channel="x", flat_area=Nx * delta_k, flat_time=ro_duration, system=system
+    )
+    adc = pp.make_adc(
+        num_samples=Nx, duration=gx.flat_time, delay=gx.rise_time, system=system
+    )
+    gx_pre = pp.make_trapezoid(
+        channel="x", area=-gx.area / 2, duration=1e-3, system=system
+    )
+    gz_reph = pp.make_trapezoid(
+        channel="z", area=-gz.area / 2, duration=1e-3, system=system
+    )
+    phase_areas = -(np.arange(Ny) - Ny / 2) * delta_k
+
+    # Gradient spoiling
+    gx_spoil = pp.make_trapezoid(channel="x", area=2 * Nx * delta_k, system=system)
+    gz_spoil = pp.make_trapezoid(channel="z", area=4 / slice_thickness, system=system)
+
+    # Calculate timing
+    delay_TE = (
+        math.ceil(
+            (
+                TE
+                - pp.calc_duration(gx_pre)
+                - gz.fall_time
+                - gz.flat_time / 2
+                - pp.calc_duration(gx) / 2
+            )
+            / seq.grad_raster_time
+        )
+        * seq.grad_raster_time
+    )
+    delay_TR = (
+        math.ceil(
+            (
+                TR
+                - pp.calc_duration(gz)
+                - pp.calc_duration(gx_pre)
+                - pp.calc_duration(gx)
+                - delay_TE
+            )
+            / seq.grad_raster_time
+        )
+        * seq.grad_raster_time
+    )
+    assert np.all(delay_TE >= 0)
+    assert np.all(delay_TR >= pp.calc_duration(gx_spoil, gz_spoil))
+
+    rf_phase = 0
+    rf_inc = 0
+
+    seq.add_block(pp.make_label(label="REV", type="SET", value=1))
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    # Loop over slices
+    for s in range(n_slices):
+        rf.freq_offset = gz.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+        # Loop over phase encodes and define sequence blocks
+        for i in range(Ny):
+            rf.phase_offset = rf_phase / 180 * np.pi
+            adc.phase_offset = rf_phase / 180 * np.pi
+            rf_inc = divmod(rf_inc + rf_spoiling_inc, 360.0)[1]
+            rf_phase = divmod(rf_phase + rf_inc, 360.0)[1]
+
+            seq.add_block(rf, gz)
+            gy_pre = pp.make_trapezoid(
+                channel="y",
+                area=phase_areas[i],
+                duration=pp.calc_duration(gx_pre),
+                system=system,
+            )
+            seq.add_block(gx_pre, gy_pre, gz_reph)
+            seq.add_block(pp.make_delay(delay_TE))
+            seq.add_block(gx, adc)
+            gy_pre.amplitude = -gy_pre.amplitude
+            spoil_block_contents = [pp.make_delay(delay_TR), gx_spoil, gy_pre, gz_spoil]
+            if i != Ny - 1:
+                spoil_block_contents.append(
+                    pp.make_label(type="INC", label="LIN", value=1)
+                )
+            else:
+                spoil_block_contents.extend(
+                    [
+                        pp.make_label(type="SET", label="LIN", value=0),
+                        pp.make_label(type="INC", label="SLC", value=1),
+                    ]
+                )
+            seq.add_block(*spoil_block_contents)
+
+    ok, error_report = seq.check_timing()
+
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot(label="lin", time_range=np.array([0, 32]) * TR, time_disp="ms")
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        # Prepare the sequence output for the scanner
+        seq.set_definition(key="FOV", value=[fov, fov, slice_thickness * n_slices])
+        seq.set_definition(key="Name", value="gre_label")
+
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 326 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_haste.py

@@ -0,0 +1,326 @@
+import math
+import warnings
+
+import numpy as np
+
+from pypulseq.Sequence.sequence import Sequence
+from pypulseq.calc_rf_center import calc_rf_center
+from pypulseq.make_adc import make_adc
+from pypulseq.make_delay import make_delay
+from pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from pypulseq.make_sinc_pulse import make_sinc_pulse
+from pypulseq.make_trapezoid import make_trapezoid
+from pypulseq.opts import Opts
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "haste_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    dG = 250e-6
+
+    # Set system limits
+    system = Opts(
+        max_grad=30,
+        grad_unit="mT/m",
+        max_slew=170,
+        slew_unit="T/m/s",
+        rf_ringdown_time=100e-6,
+        rf_dead_time=100e-6,
+        adc_dead_time=10e-6,
+    )
+
+    seq = Sequence(system=system)  # Create a new sequence object
+    fov = 256e-3  # Define FOV and resolution
+    Ny_pre = 8
+    Nx, Ny = 128, 128
+    n_echo = int(Ny / 2 + Ny_pre)  # Number of echoes
+    n_slices = 1
+    rf_flip = 180  # Flip angle
+    if isinstance(rf_flip, int):
+        rf_flip = np.zeros(n_echo) + rf_flip
+    slice_thickness = 5e-3  # Slice thickness
+    TE = 12e-3  # Echo time
+    TR = 2000e-3  # Repetition time
+
+    sampling_time = 6.4e-3
+    readout_time = sampling_time + 2 * system.adc_dead_time
+    t_ex = 2.5e-3
+    t_ex_wd = t_ex + system.rf_ringdown_time + system.rf_dead_time
+    t_ref = 2e-3
+    tf_ref_wd = t_ref + system.rf_ringdown_time + system.rf_dead_time
+    t_sp = 0.5 * (TE - readout_time - tf_ref_wd)
+    t_sp_ex = 0.5 * (TE - t_ex_wd - tf_ref_wd)
+    fspR = 1.0
+    fspS = 0.5
+
+    rfex_phase = math.pi / 2
+    rfref_phase = 0
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    # Create 90 degree slice selection pulse and gradient
+    flipex = 90 * math.pi / 180
+    rfex, gz, _ = make_sinc_pulse(
+        flip_angle=flipex,
+        system=system,
+        duration=t_ex,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        phase_offset=rfex_phase,
+        return_gz=True,
+    )
+    GS_ex = make_trapezoid(
+        channel="z",
+        system=system,
+        amplitude=gz.amplitude,
+        flat_time=t_ex_wd,
+        rise_time=dG,
+    )
+
+    flipref = rf_flip[0] * math.pi / 180
+    rfref, gz, _ = make_sinc_pulse(
+        flip_angle=flipref,
+        system=system,
+        duration=t_ref,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        phase_offset=rfref_phase,
+        use="refocusing",
+        return_gz=True,
+    )
+    GS_ref = make_trapezoid(
+        channel="z",
+        system=system,
+        amplitude=GS_ex.amplitude,
+        flat_time=tf_ref_wd,
+        rise_time=dG,
+    )
+
+    AGS_ex = GS_ex.area / 2
+    GS_spr = make_trapezoid(
+        channel="z",
+        system=system,
+        area=AGS_ex * (1 + fspS),
+        duration=t_sp,
+        rise_time=dG,
+    )
+    GS_spex = make_trapezoid(
+        channel="z", system=system, area=AGS_ex * fspS, duration=t_sp_ex, rise_time=dG
+    )
+
+    delta_k = 1 / fov
+    k_width = Nx * delta_k
+
+    GR_acq = make_trapezoid(
+        channel="x",
+        system=system,
+        flat_area=k_width,
+        flat_time=readout_time,
+        rise_time=dG,
+    )
+    adc = make_adc(num_samples=Nx, duration=sampling_time, delay=system.adc_dead_time)
+    GR_spr = make_trapezoid(
+        channel="x", system=system, area=GR_acq.area * fspR, duration=t_sp, rise_time=dG
+    )
+    GR_spex = make_trapezoid(
+        channel="x",
+        system=system,
+        area=GR_acq.area * (1 + fspR),
+        duration=t_sp_ex,
+        rise_time=dG,
+    )
+
+    AGR_spr = GR_spr.area
+    AGR_preph = GR_acq.area / 2 + AGR_spr
+    GR_preph = make_trapezoid(
+        channel="x", system=system, area=AGR_preph, duration=t_sp_ex, rise_time=dG
+    )
+
+    n_ex = 1
+    PE_order = np.arange(-Ny_pre, Ny + 1).T
+    phase_areas = PE_order * delta_k
+
+    # Split gradients and recombine into blocks
+    GS1_times = np.array([0, GS_ex.rise_time])
+    GS1_amp = np.array([0, GS_ex.amplitude])
+    GS1 = make_extended_trapezoid(channel="z", times=GS1_times, amplitudes=GS1_amp)
+
+    GS2_times = np.array([0, GS_ex.flat_time])
+    GS2_amp = np.array([GS_ex.amplitude, GS_ex.amplitude])
+    GS2 = make_extended_trapezoid(channel="z", times=GS2_times, amplitudes=GS2_amp)
+
+    GS3_times = np.array(
+        [
+            0,
+            GS_spex.rise_time,
+            GS_spex.rise_time + GS_spex.flat_time,
+            GS_spex.rise_time + GS_spex.flat_time + GS_spex.fall_time,
+        ]
+    )
+    GS3_amp = np.array(
+        [GS_ex.amplitude, GS_spex.amplitude, GS_spex.amplitude, GS_ref.amplitude]
+    )
+    GS3 = make_extended_trapezoid(channel="z", times=GS3_times, amplitudes=GS3_amp)
+
+    GS4_times = np.array([0, GS_ref.flat_time])
+    GS4_amp = np.array([GS_ref.amplitude, GS_ref.amplitude])
+    GS4 = make_extended_trapezoid(channel="z", times=GS4_times, amplitudes=GS4_amp)
+
+    GS5_times = np.array(
+        [
+            0,
+            GS_spr.rise_time,
+            GS_spr.rise_time + GS_spr.flat_time,
+            GS_spr.rise_time + GS_spr.flat_time + GS_spr.fall_time,
+        ]
+    )
+    GS5_amp = np.array([GS_ref.amplitude, GS_spr.amplitude, GS_spr.amplitude, 0])
+    GS5 = make_extended_trapezoid(channel="z", times=GS5_times, amplitudes=GS5_amp)
+
+    GS7_times = np.array(
+        [
+            0,
+            GS_spr.rise_time,
+            GS_spr.rise_time + GS_spr.flat_time,
+            GS_spr.rise_time + GS_spr.flat_time + GS_spr.fall_time,
+        ]
+    )
+    GS7_amp = np.array([0, GS_spr.amplitude, GS_spr.amplitude, GS_ref.amplitude])
+    GS7 = make_extended_trapezoid(channel="z", times=GS7_times, amplitudes=GS7_amp)
+
+    # Readout gradient
+    GR3 = GR_preph
+
+    GR5_times = np.array(
+        [
+            0,
+            GR_spr.rise_time,
+            GR_spr.rise_time + GR_spr.flat_time,
+            GR_spr.rise_time + GR_spr.flat_time + GR_spr.fall_time,
+        ]
+    )
+    GR5_amp = np.array([0, GR_spr.amplitude, GR_spr.amplitude, GR_acq.amplitude])
+    GR5 = make_extended_trapezoid(channel="x", times=GR5_times, amplitudes=GR5_amp)
+
+    GR6_times = np.array([0, readout_time])
+    GR6_amp = np.array([GR_acq.amplitude, GR_acq.amplitude])
+    GR6 = make_extended_trapezoid(channel="x", times=GR6_times, amplitudes=GR6_amp)
+
+    GR7_times = np.array(
+        [
+            0,
+            GR_spr.rise_time,
+            GR_spr.rise_time + GR_spr.flat_time,
+            GR_spr.rise_time + GR_spr.flat_time + GR_spr.fall_time,
+        ]
+    )
+    GR7_amp = np.array([GR_acq.amplitude, GR_spr.amplitude, GR_spr.amplitude, 0])
+    GR7 = make_extended_trapezoid(channel="x", times=GR7_times, amplitudes=GR7_amp)
+
+    # Fill-times
+    tex = GS1.shape_dur + GS2.shape_dur + GS3.shape_dur
+    tref = GS4.shape_dur + GS5.shape_dur + GS7.shape_dur + readout_time
+    tend = GS4.shape_dur + GS5.shape_dur
+    TE_train = tex + n_echo * tref + tend
+    TR_fill = (TR - n_slices * TE_train) / n_slices  # Round to gradient raster
+
+    TR_fill = system.grad_raster_time * round(TR_fill / system.grad_raster_time)
+    if TR_fill < 0:
+        TR_fill = 1e-3
+        warnings.warn(
+            f"TR too short, adapted to include all slices to: {1000 * n_slices * (TE_train + TR_fill)} ms"
+        )
+    else:
+        print(f"TR fill: {1000 * TR_fill} ms")
+    delay_TR = make_delay(TR_fill)
+    delay_end = make_delay(5)
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    # Define sequence blocks
+    for k_ex in range(n_ex):
+        for s in range(n_slices):
+            rfex.freq_offset = (
+                GS_ex.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+            )
+            rfref.freq_offset = (
+                GS_ref.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+            )
+            # Align the phase for off-center slices
+            rfex.phase_offset = (
+                rfex_phase - 2 * math.pi * rfex.freq_offset * calc_rf_center(rfex)[0]
+            )
+            rfref.phase_offset = (
+                rfref_phase - 2 * math.pi * rfref.freq_offset * calc_rf_center(rfref)[0]
+            )
+
+            seq.add_block(GS1)
+            seq.add_block(GS2, rfex)
+            seq.add_block(GS3, GR3)
+
+            for k_ech in range(n_echo):
+                if k_ex >= 0:
+                    phase_area = phase_areas[k_ech]
+                else:
+                    phase_area = 0
+
+                GP_pre = make_trapezoid(
+                    channel="y",
+                    system=system,
+                    area=phase_area,
+                    duration=t_sp,
+                    rise_time=dG,
+                )
+                GP_rew = make_trapezoid(
+                    channel="y",
+                    system=system,
+                    area=-phase_area,
+                    duration=t_sp,
+                    rise_time=dG,
+                )
+
+                seq.add_block(GS4, rfref)
+                seq.add_block(GS5, GR5, GP_pre)
+
+                if k_ex >= 0:
+                    seq.add_block(GR6, adc)
+                else:
+                    seq.add_block(GR6)
+
+                seq.add_block(GS7, GR7, GP_rew)
+
+            seq.add_block(GS4)
+            seq.add_block(GS5)
+            seq.add_block(delay_TR)
+
+    seq.add_block(delay_end)
+
+    # Check whether the timing of the sequence is correct
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        seq.write(seq_filename)
+
+
+# SETUPeq")
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 142 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_radial_gre.py

@@ -0,0 +1,142 @@
+import numpy as np
+
+import pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "gre_radial_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    seq = pp.Sequence()  # Create a new sequence object
+    fov = 260e-3
+    Nx = 320  # Define FOV and resolution
+    alpha = 10  # Flip angle
+    slice_thickness = 3e-3  # Slice thickness
+    TE = 8e-3  # Echo time
+    TR = 20e-3  # Repetition time
+    Nr = 256  # Number of radial spokes
+    N_dummy = 20  # Number of dummy scans
+    delta = np.pi / Nr  # Angular increment
+
+    rf_spoiling_inc = 117  # RF spoiling increment
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=28,
+        grad_unit="mT/m",
+        max_slew=120,
+        slew_unit="T/m/s",
+        rf_ringdown_time=20e-6,
+        rf_dead_time=100e-6,
+        adc_dead_time=10e-6,
+    )
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    # Create alpha-degree slice selection pulse and gradient
+    rf, gz, _ = pp.make_sinc_pulse(
+        apodization=0.5,
+        duration=4e-3,
+        flip_angle=alpha * np.pi / 180,
+        slice_thickness=slice_thickness,
+        system=system,
+        time_bw_product=4,
+        return_gz=True,
+    )
+
+    # Define other gradients and ADC events
+    deltak = 1 / fov
+    gx = pp.make_trapezoid(
+        channel="x", flat_area=Nx * deltak, flat_time=6.4e-3 / 5, system=system
+    )
+    adc = pp.make_adc(
+        num_samples=Nx, duration=gx.flat_time, delay=gx.rise_time, system=system
+    )
+    gx_pre = pp.make_trapezoid(
+        channel="x", area=-gx.area / 2 - deltak / 2, duration=2e-3, system=system
+    )
+    gz_reph = pp.make_trapezoid(
+        channel="z", area=-gz.area / 2, duration=2e-3, system=system
+    )
+    # Gradient spoiling
+    gx_spoil = pp.make_trapezoid(channel="x", area=0.5 * Nx * deltak, system=system)
+    gz_spoil = pp.make_trapezoid(channel="z", area=4 / slice_thickness, system=system)
+
+    # Calculate timing
+    delay_TE = (
+        np.ceil(
+            (
+                TE
+                - pp.calc_duration(gx_pre)
+                - gz.fall_time
+                - gz.flat_time / 2
+                - pp.calc_duration(gx) / 2
+            )
+            / seq.grad_raster_time
+        )
+        * seq.grad_raster_time
+    )
+    delay_TR = (
+        np.ceil(
+            (
+                TR
+                - pp.calc_duration(gx_pre)
+                - pp.calc_duration(gz)
+                - pp.calc_duration(gx)
+                - delay_TE
+            )
+            / seq.grad_raster_time
+        )
+        * seq.grad_raster_time
+    )
+    assert np.all(delay_TR) > pp.calc_duration(gx_spoil, gz_spoil)
+    rf_phase = 0
+    rf_inc = 0
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    for i in range(-N_dummy, Nr + 1):
+        rf.phase_offset = rf_phase / 180 * np.pi
+        adc.phase_offset = rf_phase / 180 * np.pi
+
+        rf_inc = divmod(rf_inc + rf_spoiling_inc, 360.0)[1]
+        rf_phase = divmod(rf_inc + rf_phase, 360.0)[1]
+
+        seq.add_block(rf, gz)
+        phi = delta * (i - 1)
+        seq.add_block(*pp.rotate(gx_pre, gz_reph, angle=phi, axis="z"))
+        seq.add_block(pp.make_delay(delay_TE))
+        if i > 0:
+            seq.add_block(*pp.rotate(gx, adc, angle=phi, axis="z"))
+        else:
+            seq.add_block(*pp.rotate(gx, angle=phi, axis="z"))
+        seq.add_block(
+            *pp.rotate(gx_spoil, gz_spoil, pp.make_delay(delay_TR), angle=phi, axis="z")
+        )
+
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed! Error listing follows:")
+        print(error_report)
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        seq.set_definition(key="FOV", value=[fov, fov, slice_thickness])
+        seq.set_definition(key="Name", value="gre_rad")
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 332 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_tse.py

@@ -0,0 +1,332 @@
+import math
+import warnings
+
+import numpy as np
+
+import LF_scanner.pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "tse_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    dG = 250e-6
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=32,
+        grad_unit="mT/m",
+        max_slew=130,
+        slew_unit="T/m/s",
+        rf_ringdown_time=100e-6,
+        rf_dead_time=100e-6,
+        adc_dead_time=10e-6,
+    )
+
+    seq = pp.Sequence(system)  # Create a new sequence object
+    fov = 256e-3  # Define FOV and resolution
+    Nx, Ny = 32, 32
+    n_echo = 16  # Number of echoes
+    n_slices = 3
+    rf_flip = 180  # Flip angle
+    if isinstance(rf_flip, int):
+        rf_flip = np.zeros(n_echo) + rf_flip
+    slice_thickness = 5e-3
+    TE = 12e-3  # Echo time
+    TR = 2000e-3  # Repetition time
+
+    sampling_time = 6.4e-3
+    readout_time = sampling_time + 2 * system.adc_dead_time
+    t_ex = 2.5e-3
+    t_exwd = t_ex + system.rf_ringdown_time + system.rf_dead_time
+    t_ref = 2e-3
+    t_refwd = t_ref + system.rf_ringdown_time + system.rf_dead_time
+    t_sp = 0.5 * (TE - readout_time - t_refwd)
+    t_spex = 0.5 * (TE - t_exwd - t_refwd)
+    fsp_r = 1
+    fsp_s = 0.5
+
+    rf_ex_phase = np.pi / 2
+    rf_ref_phase = 0
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    flip_ex = 90 * np.pi / 180
+    rf_ex, gz, _ = pp.make_sinc_pulse(
+        flip_angle=flip_ex,
+        system=system,
+        duration=t_ex,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        phase_offset=rf_ex_phase,
+        return_gz=True,
+    )
+    gs_ex = pp.make_trapezoid(
+        channel="z",
+        system=system,
+        amplitude=gz.amplitude,
+        flat_time=t_exwd,
+        rise_time=dG,
+    )
+
+    flip_ref = rf_flip[0] * np.pi / 180
+    rf_ref, gz, _ = pp.make_sinc_pulse(
+        flip_angle=flip_ref,
+        system=system,
+        duration=t_ref,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=4,
+        phase_offset=rf_ref_phase,
+        use="refocusing",
+        return_gz=True,
+    )
+    gs_ref = pp.make_trapezoid(
+        channel="z",
+        system=system,
+        amplitude=gs_ex.amplitude,
+        flat_time=t_refwd,
+        rise_time=dG,
+    )
+
+    ags_ex = gs_ex.area / 2
+    gs_spr = pp.make_trapezoid(
+        channel="z",
+        system=system,
+        area=ags_ex * (1 + fsp_s),
+        duration=t_sp,
+        rise_time=dG,
+    )
+    gs_spex = pp.make_trapezoid(
+        channel="z", system=system, area=ags_ex * fsp_s, duration=t_spex, rise_time=dG
+    )
+
+    delta_k = 1 / fov
+    k_width = Nx * delta_k
+
+    gr_acq = pp.make_trapezoid(
+        channel="x",
+        system=system,
+        flat_area=k_width,
+        flat_time=readout_time,
+        rise_time=dG,
+    )
+    adc = pp.make_adc(
+        num_samples=Nx, duration=sampling_time, delay=system.adc_dead_time
+    )
+    gr_spr = pp.make_trapezoid(
+        channel="x",
+        system=system,
+        area=gr_acq.area * fsp_r,
+        duration=t_sp,
+        rise_time=dG,
+    )
+
+    agr_spr = gr_spr.area
+    agr_preph = gr_acq.area / 2 + agr_spr
+    gr_preph = pp.make_trapezoid(
+        channel="x", system=system, area=agr_preph, duration=t_spex, rise_time=dG
+    )
+
+    # Phase-encoding
+    n_ex = math.floor(Ny / n_echo)
+    pe_steps = np.arange(1, n_echo * n_ex + 1) - 0.5 * n_echo * n_ex - 1
+    if divmod(n_echo, 2)[1] == 0:
+        pe_steps = np.roll(pe_steps, [0, int(-np.round(n_ex / 2))])
+    pe_order = pe_steps.reshape((n_ex, n_echo), order="F").T
+    phase_areas = pe_order * delta_k
+
+    # Split gradients and recombine into blocks
+    gs1_times = np.array([0, gs_ex.rise_time])
+    gs1_amp = np.array([0, gs_ex.amplitude])
+    gs1 = pp.make_extended_trapezoid(channel="z", times=gs1_times, amplitudes=gs1_amp)
+
+    gs2_times = np.array([0, gs_ex.flat_time])
+    gs2_amp = np.array([gs_ex.amplitude, gs_ex.amplitude])
+    gs2 = pp.make_extended_trapezoid(channel="z", times=gs2_times, amplitudes=gs2_amp)
+
+    gs3_times = np.array(
+        [
+            0,
+            gs_spex.rise_time,
+            gs_spex.rise_time + gs_spex.flat_time,
+            gs_spex.rise_time + gs_spex.flat_time + gs_spex.fall_time,
+        ]
+    )
+    gs3_amp = np.array(
+        [gs_ex.amplitude, gs_spex.amplitude, gs_spex.amplitude, gs_ref.amplitude]
+    )
+    gs3 = pp.make_extended_trapezoid(channel="z", times=gs3_times, amplitudes=gs3_amp)
+
+    gs4_times = np.array([0, gs_ref.flat_time])
+    gs4_amp = np.array([gs_ref.amplitude, gs_ref.amplitude])
+    gs4 = pp.make_extended_trapezoid(channel="z", times=gs4_times, amplitudes=gs4_amp)
+
+    gs5_times = np.array(
+        [
+            0,
+            gs_spr.rise_time,
+            gs_spr.rise_time + gs_spr.flat_time,
+            gs_spr.rise_time + gs_spr.flat_time + gs_spr.fall_time,
+        ]
+    )
+    gs5_amp = np.array([gs_ref.amplitude, gs_spr.amplitude, gs_spr.amplitude, 0])
+    gs5 = pp.make_extended_trapezoid(channel="z", times=gs5_times, amplitudes=gs5_amp)
+
+    gs7_times = np.array(
+        [
+            0,
+            gs_spr.rise_time,
+            gs_spr.rise_time + gs_spr.flat_time,
+            gs_spr.rise_time + gs_spr.flat_time + gs_spr.fall_time,
+        ]
+    )
+    gs7_amp = np.array([0, gs_spr.amplitude, gs_spr.amplitude, gs_ref.amplitude])
+    gs7 = pp.make_extended_trapezoid(channel="z", times=gs7_times, amplitudes=gs7_amp)
+
+    # Readout gradient
+    gr3 = gr_preph
+
+    gr5_times = np.array(
+        [
+            0,
+            gr_spr.rise_time,
+            gr_spr.rise_time + gr_spr.flat_time,
+            gr_spr.rise_time + gr_spr.flat_time + gr_spr.fall_time,
+        ]
+    )
+    gr5_amp = np.array([0, gr_spr.amplitude, gr_spr.amplitude, gr_acq.amplitude])
+    gr5 = pp.make_extended_trapezoid(channel="x", times=gr5_times, amplitudes=gr5_amp)
+
+    gr6_times = np.array([0, readout_time])
+    gr6_amp = np.array([gr_acq.amplitude, gr_acq.amplitude])
+    gr6 = pp.make_extended_trapezoid(channel="x", times=gr6_times, amplitudes=gr6_amp)
+
+    gr7_times = np.array(
+        [
+            0,
+            gr_spr.rise_time,
+            gr_spr.rise_time + gr_spr.flat_time,
+            gr_spr.rise_time + gr_spr.flat_time + gr_spr.fall_time,
+        ]
+    )
+    gr7_amp = np.array([gr_acq.amplitude, gr_spr.amplitude, gr_spr.amplitude, 0])
+    gr7 = pp.make_extended_trapezoid(channel="x", times=gr7_times, amplitudes=gr7_amp)
+
+    # Fill-times
+    t_ex = pp.calc_duration(gs1) + pp.calc_duration(gs2) + pp.calc_duration(gs3)
+    t_ref = (
+        pp.calc_duration(gs4)
+        + pp.calc_duration(gs5)
+        + pp.calc_duration(gs7)
+        + readout_time
+    )
+    t_end = pp.calc_duration(gs4) + pp.calc_duration(gs5)
+
+    a = pp.calc_duration(gs2)/2 + pp.calc_duration(gs3) + pp.calc_duration(gs4)/2
+    b = pp.calc_duration(gs5) + pp.calc_duration(gs4)/2 + pp.calc_duration(gr6)/2
+    c = pp.calc_duration(gr6) + pp.calc_duration(gs5) + pp.calc_duration(gs7) + pp.calc_duration(gs4)
+
+
+    TE_train = t_ex + n_echo * t_ref + t_end
+    TR_fill = (TR - n_slices * TE_train) / n_slices
+    # Round to gradient raster
+    TR_fill = system.grad_raster_time * np.round(TR_fill / system.grad_raster_time)
+    if TR_fill < 0:
+        TR_fill = 1e-3
+        warnings.warn(
+            f"TR too short, adapted to include all slices to: {1000 * n_slices * (TE_train + TR_fill)} ms"
+        )
+    else:
+        print(f"TR fill: {1000 * TR_fill} ms")
+    delay_TR = pp.make_delay(TR_fill)
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    #for k_ex in range(n_ex + 1):
+    for k_ex in range(n_ex):
+        for s in range(n_slices):
+            rf_ex.freq_offset = (
+                gs_ex.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+            )
+            rf_ref.freq_offset = (
+                gs_ref.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+            )
+            rf_ex.phase_offset = (
+                rf_ex_phase
+                - 2 * np.pi * rf_ex.freq_offset * pp.calc_rf_center(rf_ex)[0]
+            )
+            rf_ref.phase_offset = (
+                rf_ref_phase
+                - 2 * np.pi * rf_ref.freq_offset * pp.calc_rf_center(rf_ref)[0]
+            )
+
+            seq.add_block(gs1)
+            seq.add_block(gs2, rf_ex)
+            seq.add_block(gs3, gr3)
+
+            for k_echo in range(n_echo):
+                #if k_ex > 0:
+                if k_ex > -1:
+                    phase_area = phase_areas[k_echo, k_ex - 1]
+                else:
+                    phase_area = 0.0  # 0.0 and not 0 because -phase_area should successfully result in negative zero
+
+                gp_pre = pp.make_trapezoid(
+                    channel="y",
+                    system=system,
+                    area=phase_area,
+                    duration=t_sp,
+                    rise_time=dG,
+                )
+                gp_rew = pp.make_trapezoid(
+                    channel="y",
+                    system=system,
+                    area=-phase_area,
+                    duration=t_sp,
+                    rise_time=dG,
+                )
+                seq.add_block(gs4, rf_ref)
+                seq.add_block(gs5, gr5, gp_pre)
+                #if k_ex > 0:
+                if k_ex > -1:
+                    seq.add_block(gr6, adc)
+                else:
+                    seq.add_block(gr6)
+
+                seq.add_block(gs7, gr7, gp_rew)
+
+            seq.add_block(gs4)
+            seq.add_block(gs5)
+            seq.add_block(delay_TR)
+
+    (
+        ok,
+        error_report,
+    ) = seq.check_timing()  # Check whether the timing of the sequence is correct
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        seq.write(seq_filename)
+    #from py2jemris.seq2xml import seq2xml
+    #seq2xml(seq, seq_name='t1_TSE_matrx16x16', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 347 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_tse_new.py

@@ -0,0 +1,347 @@
+import math
+import warnings
+import json as j
+
+import numpy as np
+
+import MRI_seq.pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "tse_pypulseq.seq",
+         params_folder="C:\\MRI_sequences_files\\First_row_P\\TSE\\temp\\",
+         params_name="C:\\MRI_sequences_files\\First_row_P\\TSE\\temp\\TSE.json"
+         ):
+
+    # Reading json file according to the weightning of the image
+    with open(params_name, 'rb') as f:
+        params = j.load(f)
+
+    dG = params['dG']
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=37.8,
+        grad_unit="mT/m",
+        max_slew=121,
+        slew_unit="T/m/s",
+        rf_ringdown_time=params['rf_ringdown_time'],
+        rf_dead_time=params['rf_dead_time'],
+        adc_dead_time=params['adc_dead_time'],
+        rf_raster_time=params['rf_raster_time'],
+        grad_raster_time=params['grad_raster_time'],
+        block_duration_raster=params['grad_raster_time'],
+        adc_raster_time=1 / (params['BW_pixel'] * params['Nf'])
+    )
+
+    seq = pp.Sequence(system)  # Create a new sequence object
+    fov = params['FoV_f']  # Define FOV and resolution
+    Nx, Ny = params['Nf'] , params['Np']
+    n_echo = 16  # Number of echoes
+    n_slices = 1
+    rf_flip = 180  # Flip angle
+    if isinstance(rf_flip, int):
+        rf_flip = np.zeros(n_echo) + rf_flip
+    slice_thickness = 5e-3
+    TE = params['ES'] # Echo time
+    TR = params['TR']  # Repetition time
+
+    readout_time = round(1 / params['BW_pixel'], 8)
+    sampling_time = readout_time-2 * system.adc_dead_time
+    t_ex = params['t_ex']
+    t_exwd = t_ex + 2*system.rf_dead_time
+    t_ref = params['t_ref']
+    t_refwd = t_ref + 2*system.rf_dead_time
+    t_sp = 0.5 * (TE - readout_time - t_refwd)
+    t_spex = 0.5 * (TE - t_exwd - t_refwd)
+    fsp_r = 1
+    fsp_s = 0.5
+
+    rf_ex_phase = np.pi / 2
+    rf_ref_phase = 0
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    flip_ex = 90 * np.pi / 180
+    rf_ex, gz, _ = pp.make_sinc_pulse(
+        flip_angle=flip_ex,
+        system=system,
+        duration=t_ex,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=params['t_BW_product_ex'],
+        phase_offset=rf_ex_phase,
+        return_gz=True,
+    )
+    gs_ex = pp.make_trapezoid(
+        channel="z",
+        system=system,
+        amplitude=gz.amplitude,
+        flat_time=t_exwd,
+        rise_time=dG,
+    )
+
+    flip_ref = rf_flip[0] * np.pi / 180
+    rf_ref, gz, _ = pp.make_sinc_pulse(
+        flip_angle=flip_ref,
+        system=system,
+        duration=t_ref,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=params['t_BW_product_ref'],
+        phase_offset=rf_ref_phase,
+        use="refocusing",
+        return_gz=True,
+    )
+    gs_ref = pp.make_trapezoid(
+        channel="z",
+        system=system,
+        amplitude=gs_ex.amplitude,
+        flat_time=t_refwd,
+        rise_time=dG,
+    )
+
+    ags_ex = gs_ex.area / 2
+    gs_spr = pp.make_trapezoid(
+        channel="z",
+        system=system,
+        area=ags_ex * (1 + fsp_s),
+        duration=t_sp,
+        rise_time=dG,
+    )
+    gs_spex = pp.make_trapezoid(
+        channel="z", system=system, area=ags_ex * fsp_s, duration=t_spex, rise_time=dG
+    )
+
+    delta_k = 1 / fov
+    k_width = Nx * delta_k
+
+    gr_acq = pp.make_trapezoid(
+        channel="x",
+        system=system,
+        flat_area=k_width,
+        flat_time=readout_time,
+        rise_time=dG,
+    )
+    adc = pp.make_adc(
+        num_samples=Nx, duration=sampling_time, delay=system.adc_dead_time
+    )
+    gr_spr = pp.make_trapezoid(
+        channel="x",
+        system=system,
+        area=gr_acq.area * fsp_r,
+        duration=t_sp,
+        rise_time=dG,
+    )
+
+    agr_spr = gr_spr.area
+    agr_preph = gr_acq.area / 2 + agr_spr
+    gr_preph = pp.make_trapezoid(
+        channel="x", system=system, area=agr_preph, duration=t_spex, rise_time=dG
+    )
+
+    # Phase-encoding
+    n_ex = math.floor(Ny / n_echo)
+    pe_steps = np.arange(1, n_echo * n_ex + 1) - 0.5 * n_echo * n_ex - 1
+    if divmod(n_echo, 2)[1] == 0:
+        pe_steps = np.roll(pe_steps, [0, int(-np.round(n_ex / 2))])
+    pe_order = pe_steps.reshape((n_ex, n_echo), order="F").T
+    phase_areas = pe_order * delta_k
+
+    # Split gradients and recombine into blocks
+    gs1_times = np.array([0, gs_ex.rise_time])
+    gs1_amp = np.array([0, gs_ex.amplitude])
+    gs1 = pp.make_extended_trapezoid(channel="z", times=gs1_times, amplitudes=gs1_amp)
+
+    gs2_times = np.array([0, gs_ex.flat_time])
+    gs2_amp = np.array([gs_ex.amplitude, gs_ex.amplitude])
+    gs2 = pp.make_extended_trapezoid(channel="z", times=gs2_times, amplitudes=gs2_amp)
+
+    gs3_times = np.array(
+        [
+            0,
+            gs_spex.rise_time,
+            gs_spex.rise_time + gs_spex.flat_time,
+            gs_spex.rise_time + gs_spex.flat_time + gs_spex.fall_time,
+        ]
+    )
+    gs3_amp = np.array(
+        [gs_ex.amplitude, gs_spex.amplitude, gs_spex.amplitude, gs_ref.amplitude]
+    )
+    gs3 = pp.make_extended_trapezoid(channel="z", times=gs3_times, amplitudes=gs3_amp)
+
+    gs4_times = np.array([0, gs_ref.flat_time])
+    gs4_amp = np.array([gs_ref.amplitude, gs_ref.amplitude])
+    gs4 = pp.make_extended_trapezoid(channel="z", times=gs4_times, amplitudes=gs4_amp)
+
+    gs5_times = np.array(
+        [
+            0,
+            system.grad_raster_time * np.round(
+                (gs_spr.rise_time) / system.grad_raster_time),
+            system.grad_raster_time * np.round(
+                (gs_spr.rise_time + gs_spr.flat_time) / system.grad_raster_time),
+            system.grad_raster_time * np.round(
+                (gs_spr.rise_time + gs_spr.flat_time + gs_spr.fall_time) / system.grad_raster_time)
+        ]
+    )
+    gs5_amp = np.array([gs_ref.amplitude, gs_spr.amplitude, gs_spr.amplitude, 0])
+    gs5 = pp.make_extended_trapezoid(channel="z", times=gs5_times, amplitudes=gs5_amp)
+
+    gs7_times = np.array(
+        [
+            0,
+            system.grad_raster_time * np.round(
+                (gs_spr.rise_time) / system.grad_raster_time),
+            system.grad_raster_time * np.round(
+                (gs_spr.rise_time + gs_spr.flat_time) / system.grad_raster_time),
+            system.grad_raster_time * np.round(
+                (gs_spr.rise_time + gs_spr.flat_time + gs_spr.fall_time) / system.grad_raster_time)
+        ]
+    )
+    gs7_amp = np.array([0, gs_spr.amplitude, gs_spr.amplitude, gs_ref.amplitude])
+    gs7 = pp.make_extended_trapezoid(channel="z", times=gs7_times, amplitudes=gs7_amp)
+
+    # Readout gradient
+    gr3 = gr_preph
+
+    gr5_times = np.array(
+        [
+            0,
+            system.grad_raster_time * np.round((gr_spr.rise_time) / system.grad_raster_time),
+            system.grad_raster_time * np.round((gr_spr.rise_time + gr_spr.flat_time) / system.grad_raster_time),
+            system.grad_raster_time * np.round((gr_spr.rise_time + gr_spr.flat_time + gr_spr.fall_time) / system.grad_raster_time)
+        ]
+    )
+    gr5_amp = np.array([0, gr_spr.amplitude, gr_spr.amplitude, gr_acq.amplitude])
+    gr5 = pp.make_extended_trapezoid(channel="x", times=gr5_times, amplitudes=gr5_amp)
+
+    gr6_times = np.array([0, readout_time])
+    gr6_amp = np.array([gr_acq.amplitude, gr_acq.amplitude])
+    gr6 = pp.make_extended_trapezoid(channel="x", times=gr6_times, amplitudes=gr6_amp)
+
+    gr7_times = np.array(
+        [
+            0,
+            system.grad_raster_time * np.round((gr_spr.rise_time) / system.grad_raster_time),
+            system.grad_raster_time * np.round((gr_spr.rise_time + gr_spr.flat_time) / system.grad_raster_time),
+            system.grad_raster_time * np.round(
+                (gr_spr.rise_time + gr_spr.flat_time + gr_spr.fall_time) / system.grad_raster_time)
+
+        ]
+    )
+    gr7_amp = np.array([gr_acq.amplitude, gr_spr.amplitude, gr_spr.amplitude, 0])
+    gr7 = pp.make_extended_trapezoid(channel="x", times=gr7_times, amplitudes=gr7_amp)
+
+    # Fill-times
+    t_ex = pp.calc_duration(gs1) + pp.calc_duration(gs2) + pp.calc_duration(gs3)
+    t_ref = (
+        pp.calc_duration(gs4)
+        + pp.calc_duration(gs5)
+        + pp.calc_duration(gs7)
+        + readout_time
+    )
+    t_end = pp.calc_duration(gs4) + pp.calc_duration(gs5)
+
+    a = pp.calc_duration(gs2)/2 + pp.calc_duration(gs3) + pp.calc_duration(gs4)/2
+    b = pp.calc_duration(gs5) + pp.calc_duration(gs4)/2 + pp.calc_duration(gr6)/2
+    c = pp.calc_duration(gr6) + pp.calc_duration(gs5) + pp.calc_duration(gs7) + pp.calc_duration(gs4)
+
+
+    TE_train = t_ex + n_echo * t_ref + t_end
+    TR_fill = (TR - n_slices * TE_train) / n_slices
+    # Round to gradient raster
+    TR_fill = system.grad_raster_time * np.round(TR_fill / system.grad_raster_time)
+    if TR_fill < 0:
+        TR_fill = 1e-3
+        warnings.warn(
+            f"TR too short, adapted to include all slices to: {1000 * n_slices * (TE_train + TR_fill)} ms"
+        )
+    else:
+        print(f"TR fill: {1000 * TR_fill} ms")
+    delay_TR = pp.make_delay(TR_fill)
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    for k_ex in range(n_ex + 1):
+        for s in range(n_slices):
+            rf_ex.freq_offset = (
+                gs_ex.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+            )
+            rf_ref.freq_offset = (
+                gs_ref.amplitude * slice_thickness * (s - (n_slices - 1) / 2)
+            )
+            rf_ex.phase_offset = (
+                rf_ex_phase
+                - 2 * np.pi * rf_ex.freq_offset * pp.calc_rf_center(rf_ex)[0]
+            )
+            rf_ref.phase_offset = (
+                rf_ref_phase
+                - 2 * np.pi * rf_ref.freq_offset * pp.calc_rf_center(rf_ref)[0]
+            )
+
+            seq.add_block(gs1)
+            seq.add_block(gs2, rf_ex)
+            seq.add_block(gs3, gr3)
+
+            for k_echo in range(n_echo):
+                if k_ex > 0:
+                    phase_area = phase_areas[k_echo, k_ex - 1]
+                else:
+                    phase_area = 0.0  # 0.0 and not 0 because -phase_area should successfully result in negative zero
+
+                gp_pre = pp.make_trapezoid(
+                    channel="y",
+                    system=system,
+                    area=phase_area,
+                    duration=t_sp,
+                    rise_time=dG,
+                )
+                gp_rew = pp.make_trapezoid(
+                    channel="y",
+                    system=system,
+                    area=-phase_area,
+                    duration=t_sp,
+                    rise_time=dG,
+                )
+                seq.add_block(gs4, rf_ref)
+                seq.add_block(gs5, gr5, gp_pre)
+                if k_ex > 0:
+                    seq.add_block(gr6, adc)
+                else:
+                    seq.add_block(gr6)
+
+                seq.add_block(gs7, gr7, gp_rew)
+
+            seq.add_block(gs4)
+            seq.add_block(gs5)
+            seq.add_block(delay_TR)
+
+    (
+        ok,
+        error_report,
+    ) = seq.check_timing()  # Check whether the timing of the sequence is correct
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        seq.write(seq_filename)
+    #from py2jemris.seq2xml import seq2xml
+    #seq2xml(seq, seq_name='t1_TSE_matrx16x16', out_folder='C:\\MRI_seq\\new_MRI_pulse_seq\\t1_TSE')
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 180 - 0
libs/lf-scanner/pypulseq/seq_examples/scripts/write_ute.py

@@ -0,0 +1,180 @@
+"""
+A very basic UTE-like sequence, without ramp-sampling, ramp-RF. Achieves TE in the range of 300-400 us
+"""
+from copy import copy
+
+import numpy as np
+from matplotlib import pyplot as plt
+
+import pypulseq as pp
+
+
+def main(plot: bool, write_seq: bool, seq_filename: str = "ute_pypulseq.seq"):
+    # ======
+    # SETUP
+    # ======
+    seq = pp.Sequence()  # Create a new sequence object
+    fov = 250e-3  # Define FOV and resolution
+    Nx = 256
+    alpha = 10  # Flip angle
+    slice_thickness = 3e-3  # Slice thickness
+    TR = 10e-3  # Repetition tme
+    Nr = 128  # Number of radial spokes
+    delta = 2 * np.pi / Nr  # Angular increment
+    ro_duration = 2.56e-3  # Read-out time: controls RO bandwidth and T2-blurring
+    ro_os = 2  # Oversampling
+    ro_asymmetry = 1  # 0: Fully symmetric; 1: half-echo
+
+    rf_spoiling_inc = 117  # RF spoiling increment
+
+    # Set system limits
+    system = pp.Opts(
+        max_grad=28,
+        grad_unit="mT/m",
+        max_slew=100,
+        slew_unit="T/m/s",
+        rf_ringdown_time=20e-6,
+        rf_dead_time=100e-6,
+        adc_dead_time=10e-6,
+    )
+
+    # ======
+    # CREATE EVENTS
+    # ======
+    # Create alpha-degree slice selection pulse and gradient
+    rf, gz, gz_reph = pp.make_sinc_pulse(
+        flip_angle=alpha * np.pi / 180,
+        duration=1e-3,
+        slice_thickness=slice_thickness,
+        apodization=0.5,
+        time_bw_product=2,
+        center_pos=1,
+        system=system,
+        return_gz=True,
+    )
+
+    # Align RO asymmetry to ADC samples
+    Nxo = np.round(ro_os * Nx)
+    ro_asymmetry = pp.round_half_up(ro_asymmetry * Nxo / 2) / Nxo * 2
+
+    # Define other gradients and ADC events
+    delta_k = 1 / fov / (1 + ro_asymmetry)
+    ro_area = Nx * delta_k
+    gx = pp.make_trapezoid(
+        channel="x", flat_area=ro_area, flat_time=ro_duration, system=system
+    )
+    adc = pp.make_adc(
+        num_samples=Nxo, duration=gx.flat_time, delay=gx.rise_time, system=system
+    )
+    gx_pre = pp.make_trapezoid(
+        channel="x",
+        area=-(gx.area - ro_area) / 2
+        - gx.amplitude * adc.dwell / 2
+        - ro_area / 2 * (1 - ro_asymmetry),
+        system=system,
+    )
+
+    # Gradient spoiling
+    gx_spoil = pp.make_trapezoid(channel="x", area=0.2 * Nx * delta_k, system=system)
+
+    # Calculate timing
+    TE = (
+        gz.fall_time
+        + pp.calc_duration(gx_pre, gz_reph)
+        + gx.rise_time
+        + adc.dwell * Nxo / 2 * (1 - ro_asymmetry)
+    )
+    delay_TR = (
+        np.ceil(
+            (
+                TR
+                - pp.calc_duration(gx_pre, gz_reph)
+                - pp.calc_duration(gz)
+                - pp.calc_duration(gx)
+            )
+            / seq.grad_raster_time
+        )
+        * seq.grad_raster_time
+    )
+    assert np.all(delay_TR >= pp.calc_duration(gx_spoil))
+
+    print(f"TE = {TE * 1e6:.0f} us")
+
+    if pp.calc_duration(gz_reph) > pp.calc_duration(gx_pre):
+        gx_pre.delay = pp.calc_duration(gz_reph) - pp.calc_duration(gx_pre)
+
+    rf_phase = 0
+    rf_inc = 0
+
+    # ======
+    # CONSTRUCT SEQUENCE
+    # ======
+    for i in range(Nr):
+        for c in range(2):
+            rf.phase_offset = rf_phase / 180 * np.pi
+            adc.phase_offset = rf_phase / 180 * np.pi
+            rf_inc = np.mod(rf_inc + rf_spoiling_inc, 360.0)
+            rf_phase = np.mod(rf_phase + rf_inc, 360.0)
+
+            gz.amplitude = -gz.amplitude  # Alternate GZ amplitude
+            gz_reph.amplitude = -gz_reph.amplitude
+
+            seq.add_block(rf, gz)
+            phi = delta * i
+
+            gpc = copy(gx_pre)
+            gps = copy(gx_pre)
+            gpc.amplitude = gx_pre.amplitude * np.cos(phi)
+            gps.amplitude = gx_pre.amplitude * np.sin(phi)
+            gps.channel = "y"
+
+            grc = copy(gx)
+            grs = copy(gx)
+            grc.amplitude = gx.amplitude * np.cos(phi)
+            grs.amplitude = gx.amplitude * np.sin(phi)
+            grs.channel = "y"
+
+            gsc = copy(gx_spoil)
+            gss = copy(gx_spoil)
+            gsc.amplitude = gx_spoil.amplitude * np.cos(phi)
+            gss.amplitude = gx_spoil.amplitude * np.sin(phi)
+            gss.channel = "y"
+
+            seq.add_block(gpc, gps, gz_reph)
+            seq.add_block(grc, grs, adc)
+            seq.add_block(gsc, gss, pp.make_delay(delay_TR))
+
+    # Check whether the timing of the sequence is correct
+    ok, error_report = seq.check_timing()
+    if ok:
+        print("Timing check passed successfully")
+    else:
+        print("Timing check failed. Error listing follows:")
+        [print(e) for e in error_report]
+
+    # ======
+    # VISUALIZATION
+    # ======
+    if plot:
+        seq.plot()
+
+        # Plot gradients to check for gaps and optimality of the timing
+        gw = seq.waveforms_and_times()[0]
+        # Plot the entire gradient shape
+        plt.figure()
+        plt.plot(gw[0][0], gw[0][1], gw[1][0], gw[1][1], gw[2][0], gw[2][1])
+        plt.show()
+
+    # =========
+    # WRITE .SEQ
+    # =========
+    if write_seq:
+        # Prepare the sequence output for the scanner
+        seq.set_definition(key="FOV", value=[fov, fov, slice_thickness])
+        seq.set_definition(key="Name", value="UTE")
+
+        seq.write(seq_filename)
+
+
+if __name__ == "__main__":
+    main(plot=True, write_seq=True)

+ 41 - 0
libs/lf-scanner/pypulseq/sigpy_pulse_opts.py

@@ -0,0 +1,41 @@
+class SigpyPulseOpts:
+    def __init__(
+        self,
+        pulse_type: str = "slr",
+        ptype: str = "st",
+        ftype: str = "ls",
+        d1: float = 0.01,
+        d2: float = 0.01,
+        cancel_alpha_phs: bool = False,
+        n_bands: int = 3,
+        band_sep: int = 20,
+        phs_0_pt: str = "None",
+    ):
+        self.pulse_type = pulse_type
+
+        if pulse_type == "slr":
+            self.ptype = ptype
+            self.ftype = ftype
+            self.d1 = d1
+            self.d2 = d2
+            self.cancel_alpha_phs = cancel_alpha_phs
+
+        if pulse_type == "sms":
+            self.ptype = ptype
+            self.ftype = ftype
+            self.d1 = d1
+            self.d2 = d2
+            self.cancel_alpha_phs = cancel_alpha_phs
+            self.n_bands = n_bands
+            self.band_sep = band_sep
+            self.phs_0_pt = phs_0_pt
+
+    def __str__(self) -> str:
+        s = "Pulse options:"
+        s += "\nptype: " + str(self.ptype)
+        s += "\nftype: " + str(self.ftype)
+        s += "\nd1: " + str(self.d1)
+        s += "\nd2: " + str(self.d2)
+        s += "\ncancel_alpha_phs: " + str(self.cancel_alpha_phs)
+
+        return s

+ 93 - 0
libs/lf-scanner/pypulseq/split_gradient.py

@@ -0,0 +1,93 @@
+from types import SimpleNamespace
+from typing import Tuple
+
+import numpy as np
+
+from LF_scanner.pypulseq.calc_duration import calc_duration
+from LF_scanner.pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from LF_scanner.pypulseq.opts import Opts
+
+
+def split_gradient(
+    grad: SimpleNamespace, system: Opts = Opts()
+) -> Tuple[SimpleNamespace, SimpleNamespace, SimpleNamespace]:
+    """
+    Splits a trapezoidal gradient into slew up, flat top and slew down. Returns the individual gradient parts (slew up,
+    flat top and slew down) as extended trapezoid gradient objects. The delays in the individual gradient events are
+    adapted such that addGradients(...) produces an gradient equivalent to 'grad'.
+
+    See also:
+    - `pypulseq.split_gradient()`
+    - `pypulseq.make_extended_trapezoid()`
+    - `pypulseq.make_trapezoid()`
+    - `pypulseq.Sequence.sequence.Sequence.add_block()`
+    - `pypulseq.opts.Opts`
+
+    Parameters
+    ----------
+    grad : SimpleNamespace
+        Gradient event to be split into two gradient waveforms.
+    system : Opts, default=Opts()
+        System limits.
+
+    Returns
+    -------
+    grad1, grad2 : SimpleNamespace
+        Split gradient waveforms.
+
+    Raises
+    ------
+    ValueError
+         If arbitrary gradients are passed.
+         If non-gradient event is passed.
+    """
+    grad_raster_time = system.grad_raster_time
+    total_length = calc_duration(grad)
+
+    if grad.type == "trap":
+        channel = grad.channel
+        grad.delay = np.round(grad.delay / grad_raster_time) * grad_raster_time
+        grad.rise_time = np.round(grad.rise_time / grad_raster_time) * grad_raster_time
+        grad.flat_time = np.round(grad.flat_time / grad_raster_time) * grad_raster_time
+        grad.fall_time = np.round(grad.fall_time / grad_raster_time) * grad_raster_time
+
+        times = np.array([0, grad.rise_time])
+        amplitudes = np.array([0, grad.amplitude])
+        ramp_up = make_extended_trapezoid(
+            channel=channel,
+            system=system,
+            times=times,
+            amplitudes=amplitudes,
+            skip_check=True,
+        )
+        ramp_up.delay = grad.delay
+
+        times = np.array([0, grad.fall_time])
+        amplitudes = np.array([grad.amplitude, 0])
+        ramp_down = make_extended_trapezoid(
+            channel=channel,
+            system=system,
+            times=times,
+            amplitudes=amplitudes,
+            skip_check=True,
+        )
+        ramp_down.delay = total_length - grad.fall_time
+        ramp_down.t = ramp_down.t * grad_raster_time
+
+        flat_top = SimpleNamespace()
+        flat_top.type = "grad"
+        flat_top.channel = channel
+        flat_top.delay = grad.delay + grad.rise_time
+        flat_top.t = np.arange(
+            step=grad_raster_time,
+            stop=ramp_down.delay - grad_raster_time - grad.delay - grad.rise_time,
+        )
+        flat_top.waveform = grad.amplitude * np.ones(len(flat_top.t))
+        flat_top.first = grad.amplitude
+        flat_top.last = grad.amplitude
+
+        return ramp_up, flat_top, ramp_down
+    elif grad.type == "grad":
+        raise ValueError("Splitting of arbitrary gradients is not implemented yet.")
+    else:
+        raise ValueError("Splitting of unsupported event.")

+ 147 - 0
libs/lf-scanner/pypulseq/split_gradient_at.py

@@ -0,0 +1,147 @@
+from copy import deepcopy
+from types import SimpleNamespace
+from typing import Tuple, Union
+
+import numpy as np
+
+from LF_scanner.pypulseq import eps
+from LF_scanner.pypulseq.make_extended_trapezoid import make_extended_trapezoid
+from LF_scanner.pypulseq.opts import Opts
+
+
+def split_gradient_at(
+    grad: SimpleNamespace, time_point: float, system: Opts = Opts()
+) -> Union[SimpleNamespace, Tuple[SimpleNamespace, SimpleNamespace]]:
+    """
+    Splits a trapezoidal gradient into two extended trapezoids defined by the cut line. Returns the two gradient parts
+    by cutting the original 'grad' at 'time_point'. For the input type 'trapezoid' the results are returned as extended
+    trapezoids, for 'arb' as arbitrary gradient objects. The delays in the individual gradient events are adapted such
+    that add_gradients(...) produces a gradient equivalent to 'grad'.
+
+    See also:
+    - `pypulseq.split_gradient()`
+    - `pypulseq.make_extended_trapezoid()`
+    - `pypulseq.make_trapezoid()`
+    - `pypulseq.Sequence.sequence.Sequence.add_block()`
+    - `pypulseq.opts.Opts`
+
+    Parameters
+    ----------
+    grad : SimpleNamespace
+        Gradient event to be split into two gradient events.
+    time_point : float
+        Time point at which `grad` will be split into two gradient waveforms.
+    system : Opts, default=Opts()
+        System limits.
+
+    Returns
+    -------
+    grad1, grad2 : SimpleNamespace
+        Gradient waveforms after splitting.
+
+    Raises
+    ------
+    ValueError
+        If non-gradient event is passed.
+    """
+    # copy() to emulate pass-by-value; otherwise passed grad is modified
+    grad = deepcopy(grad)
+
+    grad_raster_time = system.grad_raster_time
+
+    time_index = np.round(time_point / grad_raster_time)
+    # Work around floating-point arithmetic limitation
+    time_point = np.round(time_index * grad_raster_time, 6)
+    channel = grad.channel
+
+    if grad.type == "grad":
+        # Check if we have an arbitrary gradient or an extended trapezoid
+        if np.abs(grad.tt[-1] - 0.5 * grad_raster_time) < 1e-10 and np.all(
+            np.abs(grad.tt[1:] - grad.tt[:-1] - grad_raster_time) < 1e-10
+        ):
+            # Arbitrary gradient -- trivial conversion
+            # If time point is out of range we have nothing to do
+            if time_index == 0 or time_index >= len(grad.tt):
+                return grad
+            else:
+                grad1 = grad
+                grad2 = grad
+                grad1.last = 0.5 * (
+                    grad.waveform[time_index - 1] + grad.waveform[time_index]
+                )
+                grad2.first = grad1.last
+                grad2.delay = grad.delay + grad.t[time_index]
+                grad1.t = grad.t[:time_index]
+                grad1.waveform = grad.waveform[:time_index]
+                grad2.t = grad.t[time_index:] - time_point
+                grad2.waveform = grad.waveform[time_index:]
+                return grad1, grad2
+        else:
+            # Extended trapezoid
+            times = grad.tt
+            amplitudes = grad.waveform
+    elif grad.type == "trap":
+        grad.delay = np.round(grad.delay / grad_raster_time) * grad_raster_time
+        grad.rise_time = np.round(grad.rise_time / grad_raster_time) * grad_raster_time
+        grad.flat_time = np.round(grad.flat_time / grad_raster_time) * grad_raster_time
+        grad.fall_time = np.round(grad.fall_time / grad_raster_time) * grad_raster_time
+
+        # Prepare the extended trapezoid structure
+        if grad.flat_time == 0:
+            times = [0, grad.rise_time, grad.rise_time + grad.fall_time]
+            amplitudes = [0, grad.amplitude, 0]
+        else:
+            times = [
+                0,
+                grad.rise_time,
+                grad.rise_time + grad.flat_time,
+                grad.rise_time + grad.flat_time + grad.fall_time,
+            ]
+            amplitudes = [0, grad.amplitude, grad.amplitude, 0]
+    else:
+        raise ValueError("Splitting of unsupported event.")
+
+    # If the split line is behind the gradient, there is no second gradient to create
+    if time_point >= grad.delay + times[-1]:
+        raise ValueError(
+            "Splitting of gradient at time point after the end of gradient."
+        )
+
+    # If the split line goes through the delay
+    if time_point < grad.delay:
+        times = np.insert(grad.delay + times, 0, 0)
+        amplitudes = [0, amplitudes]
+        grad.delay = 0
+    else:
+        time_point -= grad.delay
+
+    amplitudes = np.array(amplitudes)
+    times = np.array(times).round(6)  # Work around floating-point arithmetic limitation
+
+    # Sample at time point
+    amp_tp = np.interp(x=time_point, xp=times, fp=amplitudes)
+    t_eps = 1e-10
+    times1 = np.append(times[np.where(times < time_point - t_eps)], time_point)
+    amplitudes1 = np.append(amplitudes[np.where(times < time_point - t_eps)], amp_tp)
+    times2 = np.insert(times[times > time_point + t_eps], 0, time_point) - time_point
+    amplitudes2 = np.insert(amplitudes[times > time_point + t_eps], 0, amp_tp)
+
+    # Recreate gradients
+    grad1 = make_extended_trapezoid(
+        channel=channel,
+        system=system,
+        times=times1,
+        amplitudes=amplitudes1,
+        skip_check=True,
+    )
+    grad1.delay = grad.delay
+    grad2 = make_extended_trapezoid(
+        channel=channel,
+        system=system,
+        times=times2,
+        amplitudes=amplitudes2,
+        skip_check=True,
+    )
+    grad2.delay = time_point
+
+    return grad1, grad2

+ 37 - 0
libs/lf-scanner/pypulseq/supported_labels_rf_use.py

@@ -0,0 +1,37 @@
+from typing import Tuple
+
+
+def get_supported_labels() -> Tuple[
+    str, str, str, str, str, str, str, str, str, str, str, str, str
+]:
+    """
+    Returns
+    -------
+    tuple
+        Supported labels.
+    """
+    return (
+        "SLC",
+        "SEG",
+        "REP",
+        "AVG",
+        "SET",
+        "ECO",
+        "PHS",
+        "LIN",
+        "PAR",
+        "NAV",
+        "REV",
+        "SMS",
+        "PMC",
+    )
+
+
+def get_supported_rf_uses() -> Tuple[str, str, str, str, str]:
+    """
+    Returns
+    -------
+    tuple
+        Supported RF use labels.
+    """
+    return "excitation", "refocusing", "inversion", "saturation", "preparation"

+ 0 - 0
libs/lf-scanner/pypulseq/tests/__init__.py


+ 28 - 0
libs/lf-scanner/pypulseq/tests/base.py

@@ -0,0 +1,28 @@
+from pathlib import Path
+
+import numpy as np
+
+
+def main(script: callable, matlab_seq_filename: str, pypulseq_seq_filename: str):
+    path_here = Path(__file__)  # Path of this file
+    pypulseq_seq_filename = (
+        path_here.parent / pypulseq_seq_filename
+    )  # Path to PyPulseq seq
+    matlab_seq_filename = (
+        path_here.parent / "matlab_seqs" / matlab_seq_filename
+    )  # Path to MATLAB seq
+
+    # Run PyPulseq script and write seq file
+    script.main(plot=False, write_seq=True, seq_filename=str(pypulseq_seq_filename))
+
+    # Read MATLAB and PyPulseq seq files, discard header and signature
+    seq_matlab = matlab_seq_filename.read_text().splitlines()[4:-7]
+    seq_pypulseq = pypulseq_seq_filename.read_text().splitlines()[4:-7]
+
+    pypulseq_seq_filename.unlink()  # Delete PyPulseq seq
+
+    diff_lines = np.setdiff1d(seq_matlab, seq_pypulseq)  # Mismatching lines
+    percentage_diff = len(diff_lines) / len(
+        seq_matlab
+    )  # % of lines that are mismatching; we tolerate upto 0.1%
+    assert percentage_diff < 1e-3  # Unit test

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_MPRAGE.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_MPRAGE
+from pypulseq.tests import base
+
+
+class TestMPRAGE(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "mprage_matlab.seq"
+        pypulseq_seq_filename = "mprage_pypulseq.seq"
+        base.main(
+            script=write_MPRAGE,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_epi.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_epi
+from pypulseq.tests import base
+
+
+class TestEPI(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "epi_matlab.seq"
+        pypulseq_seq_filename = "epi_pypulseq.seq"
+        base.main(
+            script=write_epi,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_epi_label.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_epi_label
+from pypulseq.tests import base
+
+
+class TestEPILabel(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "epi_label_matlab.seq"
+        pypulseq_seq_filename = "epi_label_pypulseq.seq"
+        base.main(
+            script=write_epi_label,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_epi_se.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_epi_se
+from pypulseq.tests import base
+
+
+class TestEPISpinEcho(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "epi_se_matlab.seq"
+        pypulseq_seq_filename = "epi_se_pypulseq.seq"
+        base.main(
+            script=write_epi_se,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_epi_se_rs.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_epi_se_rs
+from pypulseq.tests import base
+
+
+class TestEPISpinEchoRS(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "epi_se_rs_matlab.seq"
+        pypulseq_seq_filename = "epi_se_rs_pypulseq.seq"
+        base.main(
+            script=write_epi_se_rs,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_gre.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_gre
+from pypulseq.tests import base
+
+
+class TestGRE(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "gre_matlab.seq"
+        pypulseq_seq_filename = "gre_pypulseq.seq"
+        base.main(
+            script=write_gre,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_gre_label.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_gre_label
+from pypulseq.tests import base
+
+
+class TestGRELabel(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "gre_label_matlab.seq"
+        pypulseq_seq_filename = "gre_label_pypulseq.seq"
+        base.main(
+            script=write_gre_label,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_gre_radial.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_radial_gre
+from pypulseq.tests import base
+
+
+class TestEPISpinEchoRS(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "gre_radial_matlab.seq"
+        pypulseq_seq_filename = "gre_radial_pypulseq.seq"
+        base.main(
+            script=write_radial_gre,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_haste.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_haste
+from pypulseq.tests import base
+
+
+class TestHASTE(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "haste_matlab.seq"
+        pypulseq_seq_filename = "haste_pypulseq.seq"
+        base.main(
+            script=write_haste,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 120 - 0
libs/lf-scanner/pypulseq/tests/test_sigpy.py

@@ -0,0 +1,120 @@
+# sms - check MB
+# slr - check slice profile
+
+import unittest
+
+import numpy as np
+import sigpy.mri.rf as rf
+
+from pypulseq.make_sigpy_pulse import sigpy_n_seq
+from pypulseq.opts import Opts
+from pypulseq.sigpy_pulse_opts import SigpyPulseOpts
+
+
+class TestSigpyPulseMethods(unittest.TestCase):
+    def test_slr(self):
+        print("Testing SLR design")
+
+        time_bw_product = 4
+        slice_thickness = 3e-3  # Slice thickness
+        flip_angle = np.pi / 2
+        # Set system limits
+        system = Opts(
+            max_grad=32,
+            grad_unit="mT/m",
+            max_slew=130,
+            slew_unit="T/m/s",
+            rf_ringdown_time=30e-6,
+            rf_dead_time=100e-6,
+        )
+        pulse_cfg = SigpyPulseOpts(
+            pulse_type="slr",
+            ptype="st",
+            ftype="ls",
+            d1=0.01,
+            d2=0.01,
+            cancel_alpha_phs=False,
+            n_bands=3,
+            band_sep=20,
+            phs_0_pt="None",
+        )
+        rfp, gz, _, pulse = sigpy_n_seq(
+            flip_angle=flip_angle,
+            system=system,
+            duration=3e-3,
+            slice_thickness=slice_thickness,
+            time_bw_product=4,
+            return_gz=True,
+            pulse_cfg=pulse_cfg,
+        )
+
+        [a, b] = rf.sim.abrm(
+            pulse,
+            np.arange(
+                -20 * time_bw_product, 20 * time_bw_product, 40 * time_bw_product / 2000
+            ),
+            True,
+        )
+        Mxy = 2 * np.multiply(np.conj(a), b)
+        # pl.LinePlot(Mxy)
+        # print(np.sum(np.abs(Mxy)))
+        # peaks, dict = sis.find_peaks(np.abs(Mxy),threshold=0.5, plateau_size=40)
+        plateau_widths = np.sum(np.abs(Mxy) > 0.8)
+        self.assertTrue(29, plateau_widths)
+
+    def test_sms(self):
+        print("Testing SMS design")
+
+        time_bw_product = 4
+        slice_thickness = 3e-3  # Slice thickness
+        flip_angle = np.pi / 2
+        n_bands = 3
+        # Set system limits
+        system = Opts(
+            max_grad=32,
+            grad_unit="mT/m",
+            max_slew=130,
+            slew_unit="T/m/s",
+            rf_ringdown_time=30e-6,
+            rf_dead_time=100e-6,
+        )
+        pulse_cfg = SigpyPulseOpts(
+            pulse_type="sms",
+            ptype="st",
+            ftype="ls",
+            d1=0.01,
+            d2=0.01,
+            cancel_alpha_phs=False,
+            n_bands=n_bands,
+            band_sep=20,
+            phs_0_pt="None",
+        )
+        rfp, gz, _, pulse = sigpy_n_seq(
+            flip_angle=flip_angle,
+            system=system,
+            duration=3e-3,
+            slice_thickness=slice_thickness,
+            time_bw_product=4,
+            return_gz=True,
+            pulse_cfg=pulse_cfg,
+        )
+
+        [a, b] = rf.sim.abrm(
+            pulse,
+            np.arange(
+                -20 * time_bw_product, 20 * time_bw_product, 40 * time_bw_product / 2000
+            ),
+            True,
+        )
+        Mxy = 2 * np.multiply(np.conj(a), b)
+        # pl.LinePlot(Mxy)
+        # print(np.sum(np.abs(Mxy)))
+        # peaks, dict = sis.find_peaks(np.abs(Mxy),threshold=0.5, plateau_size=40)
+        plateau_widths = np.sum(np.abs(Mxy) > 0.8)
+        self.assertEqual(
+            29 * n_bands, plateau_widths
+        )  # if slr has 29 > 0.8, then sms with MB = n_bands
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_tse.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_tse
+from pypulseq.tests import base
+
+
+class TestTSE(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "tse_matlab.seq"
+        pypulseq_seq_filename = "tse_pypulseq.seq"
+        base.main(
+            script=write_tse,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 19 - 0
libs/lf-scanner/pypulseq/tests/test_ute.py

@@ -0,0 +1,19 @@
+import unittest
+
+from pypulseq.seq_examples.scripts import write_ute
+from pypulseq.tests import base
+
+
+class TestUTE(unittest.TestCase):
+    def test_write_epi(self):
+        matlab_seq_filename = "ute_matlab.seq"
+        pypulseq_seq_filename = "ute_pypulseq.seq"
+        base.main(
+            script=write_ute,
+            matlab_seq_filename=matlab_seq_filename,
+            pypulseq_seq_filename=pypulseq_seq_filename,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 39 - 0
libs/lf-scanner/pypulseq/traj_to_grad.py

@@ -0,0 +1,39 @@
+from typing import Tuple
+
+import numpy as np
+
+from LF_scanner.pypulseq.opts import Opts
+
+
+def traj_to_grad(
+    k: np.ndarray, raster_time: float = Opts().grad_raster_time
+) -> Tuple[np.ndarray, np.ndarray]:
+    """
+    Convert k-space trajectory `k` into gradient waveform in compliance with `raster_time` gradient raster time.
+
+    Parameters
+    ----------
+    k : numpy.ndarray
+        K-space trajectory to be converted into gradient waveform.
+    raster_time : float, default=Opts().grad_raster_time
+        Gradient raster time.
+
+    Returns
+    -------
+    g : numpy.ndarray
+        Gradient waveform.
+    sr : numpy.ndarray
+        Slew rate.
+    """
+    # Compute finite difference for gradients in Hz/m
+    g = (k[1:] - k[:-1]) / raster_time
+    # Compute the slew rate
+    sr0 = (g[1:] - g[:-1]) / raster_time
+
+    # Gradient is now sampled between k-space points whilst the slew rate is between gradient points
+    sr = np.zeros(len(sr0) + 1)
+    sr[0] = sr0[0]
+    sr[1:-1] = 0.5 * (sr0[-1] + sr0[1:])
+    sr[-1] = sr0[-1]
+
+    return g, sr

+ 40 - 0
libs/lf-scanner/pypulseq/utilities/TSE_k_space_fill.py

@@ -0,0 +1,40 @@
+def TSE_k_space_fill(n_ex, ETL, k_steps, TE_eff_number, order):
+    # function defines phase encoding steps for k space filling in liner order
+    # with shifting according to the TE effective number
+
+    k_space_list_with_zero = []
+    for i in range(ETL):
+        k_space_list_with_zero.append(int((ETL - 1) * n_ex - i * n_ex))
+    # print(k_space_list_with_zero)
+    central_num = int(k_steps / 2)
+    # print(central_num)
+    index_central_line = k_space_list_with_zero.index(central_num)
+    shift = index_central_line - TE_eff_number + 1
+
+    if shift > 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+    elif shift < 0:
+        a = k_space_list_with_zero[:shift]
+        b = k_space_list_with_zero[shift:]
+        k_space_list_with_zero = b + a
+
+    if order == 'non_linear':
+        a = k_space_list_with_zero[:((shift-index_central_line)*2+1)]
+        b = k_space_list_with_zero[((shift-index_central_line)*2+1):]
+        for i in range(1, int(len(b)/2)+1):
+            a.append(b[i-1])
+            a.append(b[-i])
+        a.append(b[i])
+        k_space_list_with_zero = a
+
+    k_space_order_filing = [k_space_list_with_zero]
+    for i in range(n_ex - 1):
+        k_space_list_temp = []
+        for k in k_space_list_with_zero:
+            k_space_list_temp.append(k + i + 1)
+        k_space_order_filing.append(k_space_list_temp)
+
+
+    return k_space_order_filing

+ 0 - 0
libs/lf-scanner/pypulseq/utilities/__init__.py


+ 39 - 0
libs/lf-scanner/pypulseq/utilities/magn_prep/FS_CHESS_block.py

@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+"""
+A subroutine to add FS block.
+Requires the params structure as input.
+
+Need to think of the reequired output (pulse sequence variable itself?)
+
+@author: petrm
+"""
+
+#imports
+from LF_scanner.pypulseq.make_gauss_pulse import make_gauss_pulse
+from LF_scanner.pypulseq.make_trapezoid import make_trapezoid
+from LF_scanner.pypulseq.calc_rf_center import calc_rf_center
+from LF_scanner.pypulseq.calc_duration import calc_duration
+import numpy as np
+
+def FS_CHESS_block(params, scanner_parameters, gz90):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    flip_fs = round(110 * np.pi / 180, 3) #TODO ad parameter to GUI
+    params['B0'] = 1.5  # TODO add to GUI
+    params['FS_sat_ppm'] = -3.30  # TODO add to GUI
+    params['FS_pulse_duration'] = 8e-3  # TODO add to GUI
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+
+
+    rf_fs = make_gauss_pulse(flip_angle=flip_fs, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(params['BW_sat']), freq_offset=FS_sat_frequency)
+    #TODO
+    #rf_fs.phaseOffset=-2*pi*rf_fs.freqOffset*mr.calcRfCenter(rf_fs)
+    gx_fs = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= 4*gz90.area, rise_time=params['dG'])
+    gy_fs = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_fs),
+                           area= 4*gz90.area, rise_time=params['dG'])
+
+    return rf_fs, gx_fs, gy_fs
+
+
+

+ 32 - 0
libs/lf-scanner/pypulseq/utilities/magn_prep/IR_block.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+"""
+A subroutine to add IR block.
+Requires the params structure as input.
+
+Need to think of the reequired output (pulse sequence variable itself?)
+
+@author: petrm
+"""
+
+#imports
+from math import pi
+import numpy as np
+
+from LF_scanner.pypulseq.make_sinc_pulse import make_sinc_pulse
+from LF_scanner.pypulseq.make_delay import make_delay
+
+
+def IR_block(params, scanner_parameters):
+    #function creates inversion recovery block with delay
+    #params['IR_time'] = 0.140  # STIR # TODO add to GUI
+    #params['IR_time'] = 2.250  # FLAIR # TODO add to GUI
+    flip_ir = round(180 * pi / 180) # TODO add to GUI
+    rf_ir, gz_ir, _ = make_sinc_pulse(flip_angle=flip_ir, system=scanner_parameters, duration=params['t_ref'],
+                                      slice_thickness=params['sl_thkn'], apodization=0.5,
+                                      time_bw_product=round(params['t_BW_product_ref'], 8), phase_offset=90 * pi / 180,
+                                      return_gz=True)
+    delay_IR = np.ceil(params['TI'] / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    delay_IR = make_delay(delay_IR)
+
+    return rf_ir, gz_ir, delay_IR
+

+ 43 - 0
libs/lf-scanner/pypulseq/utilities/magn_prep/SPAIR_block.py

@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+"""
+A subroutine to add SPAIR fat suppression block.
+Requires the params structure as input.
+
+Need to think of the reequired output (pulse sequence variable itself?)
+
+@author: petrm
+"""
+
+#imports
+from math import pi
+import numpy as np
+
+from LF_scanner.pypulseq.make_delay import make_delay
+from LF_scanner.pypulseq.make_gauss_pulse import make_gauss_pulse
+from LF_scanner.pypulseq.make_trapezoid import make_trapezoid
+from LF_scanner.pypulseq.calc_duration import calc_duration
+
+def SPAIR_block(params, scanner_parameters, gz90):
+    #function creates CHESS saturation block with accompanied gx and gy spoiled gradients
+    params['B0'] = 1.5  # TODO add to GUI
+    params['FS_sat_ppm'] = -3.30  # TODO add to GUI
+    params['FS_pulse_duration'] = 0.01  # TODO add to GUI
+    #params['IR_time'] = 0.140  # SPAIR # TODO add to GUI
+    params['BW_sat'] = -176.26464
+    g_rf_area = gz90.area * 10
+
+    FS_sat_frequency = params['B0'] * 1e-6 * params['FS_sat_ppm'] * params['gamma']
+    flip_SPAIR = round(180 * pi / 180)
+
+    rf_SPAIR = make_gauss_pulse(flip_angle=flip_SPAIR, system=scanner_parameters, duration=params['FS_pulse_duration'],
+                             bandwidth=abs(params['BW_sat']), freq_offset=FS_sat_frequency)
+    gx_SPAIR = make_trapezoid(channel="x", system=scanner_parameters, delay=calc_duration(rf_SPAIR),
+                           area= g_rf_area, rise_time=params['dG'])
+    gy_SPAIR = make_trapezoid(channel="y", system=scanner_parameters, delay=calc_duration(rf_SPAIR),
+                           area= g_rf_area, rise_time=params['dG'])
+
+    delay_IR = np.ceil(params['TI'] / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    delay_IR = make_delay(delay_IR)
+
+    return rf_SPAIR, gx_SPAIR, gy_SPAIR, delay_IR
+

+ 0 - 0
libs/lf-scanner/pypulseq/utilities/magn_prep/__init__.py


+ 68 - 0
libs/lf-scanner/pypulseq/utilities/magn_prep/magn_prep.py

@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+"""
+A subroutine to calculate duration of any magnetisation preparation block.
+Requires the params structure as input.
+
+@author: petrm
+"""
+#imports
+from MRI_seq.pypulseq.utilities.magn_prep.FS_CHESS_block import FS_CHESS_block
+from MRI_seq.pypulseq.utilities.magn_prep.SPAIR_block import SPAIR_block
+from MRI_seq.pypulseq.utilities.magn_prep.IR_block import IR_block
+from MRI_seq.pypulseq.calc_duration import calc_duration
+
+
+def magn_prep_duration(params, scanner_parameters, gz90):
+
+    params['FS'] = True #TODO: add parameters to GUI
+    params['SPAIR'] = False #TODO: add parameters to GUI
+    params['IR'] = False #TODO: add parameters to GUI
+
+    magn_prep_dur = 0
+
+    if params['FS']:
+        rf_fs, gx_fs, gy_fs = FS_CHESS_block(params, scanner_parameters, gz90)
+        magn_prep_dur += calc_duration(gx_fs)
+        return magn_prep_dur
+
+    elif params['SPAIR']:
+        rf_SPAIR, gx_SPAIR, gy_SPAIR, delay_IR = SPAIR_block(params, scanner_parameters, gz90)
+        magn_prep_dur += calc_duration(gx_SPAIR)
+        magn_prep_dur += calc_duration(delay_IR)
+        return magn_prep_dur
+
+    elif params['IR']:
+        rf_ir, gz_ir, delay_IR = IR_block(params, scanner_parameters)
+        magn_prep_dur += max(calc_duration(rf_ir), calc_duration(gz_ir))
+        magn_prep_dur += calc_duration(delay_IR)
+        return magn_prep_dur
+
+    else:
+        return magn_prep_dur
+
+def magn_prep_add_block(params, scanner_parameters, gz90, seq):
+
+    params['FS'] = True  # TODO: add parameters to GUI
+    params['SPAIR'] = False  # TODO: add parameters to GUI
+    params['IR'] = False  # TODO: add parameters to GUI
+
+    if params['FS']:
+        rf_fs, gx_fs, gy_fs = FS_CHESS_block(params, scanner_parameters, gz90)
+        seq.add_block(gx_fs, gy_fs, rf_fs)
+        return seq
+
+    elif params['SPAIR']:
+        rf_SPAIR, gx_SPAIR, gy_SPAIR, delay_IR = SPAIR_block(params, scanner_parameters, gz90)
+        seq.add_block(gx_SPAIR, gy_SPAIR, rf_SPAIR)
+        seq.add_block(delay_IR)
+        return seq
+
+    elif params['IR']:
+        # TODO add correct offset from correct slice
+        rf_ir, gz_ir, delay_IR = IR_block(params, scanner_parameters)
+        seq.add_block(gz_ir, rf_ir)
+        seq.add_block(delay_IR)
+        return seq
+
+    else:
+        return seq

+ 17 - 0
libs/lf-scanner/pypulseq/utilities/phase_grad_utils.py

@@ -0,0 +1,17 @@
+import numpy as np
+
+
+def create_k_steps(k_span, steps):
+    """
+    A function that returns a k_span gradient span with odd and even gradient steps
+    """
+    k_steps = np.array(range(steps + 1))
+
+    if (np.mod(steps, 2) == 0):
+        k_steps = (k_steps - steps / 2) / (steps / 2)
+    else:
+        k_steps = (k_steps - (steps + 1) / 2) / (steps / 2)
+
+    k_steps = np.flip(k_steps, 0)
+    k_steps = np.delete(k_steps, -1)
+    return k_steps * k_span * 0.5     

+ 188 - 0
libs/lf-scanner/pypulseq/utilities/standart_RF.py

@@ -0,0 +1,188 @@
+# -*- coding: utf-8 -*-
+"""
+A subroutine functions to create different excitation and refocusing
+pulses accompanied by combined SS and spoil gradients.
+Requires the params structure as input.
+
+@author: petrm
+"""
+
+
+# import
+from MRI_seq.pypulseq.make_sinc_pulse import make_sinc_pulse
+from MRI_seq.pypulseq.make_trapezoid import make_trapezoid
+from MRI_seq.pypulseq.make_extended_trapezoid import make_extended_trapezoid
+import numpy as np
+
+
+# def tse_excitation_grad(params, scanner_parameters, area_gz_spoil, flip180):
+#
+#    return
+
+def refocusing_grad(params, scanner_parameters, area_gz_spoil, flip180, rf180_phase, spoil_duration, united: bool):
+    # Create 180 degree SS refocusing pulse with SS and spoiled gradients
+    rf180, gz_ref, _ = make_sinc_pulse(
+        flip_angle=flip180,
+        system=scanner_parameters,
+        duration=params['t_ref'],
+        slice_thickness=params['sl_thkn'],
+        apodization=0.5,
+        time_bw_product=round(params['t_BW_product_ref'], 8),
+        phase_offset=rf180_phase,
+        use="refocusing",
+        return_gz=True,
+    )
+    gz180 = make_trapezoid(channel="z", system=scanner_parameters, amplitude=gz_ref.amplitude,
+                           flat_time=gz_ref.flat_time + 2*params['rf_dead_time'])
+
+    # spoil gradient around 180 RF pulse - G_crs
+    # t_gz_spoil = (np.ceil(params['t_ref'] / 2 / scanner_parameters.grad_raster_time)
+    #               * scanner_parameters.grad_raster_time)
+
+    if spoil_duration == 'min':
+        gz_spoil1 = make_trapezoid(channel='z', system=scanner_parameters, area=area_gz_spoil,
+                                   rise_time=params['dG'], flat_time=params['dG'])
+        gz_spoil2 = make_trapezoid(channel='z', system=scanner_parameters, area=area_gz_spoil,
+                                   rise_time=params['dG'], flat_time=params['dG'])
+    else:
+        spoil_duration = float(spoil_duration)
+        spoil_duration = np.ceil(spoil_duration / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+        gz_spoil1 = make_trapezoid(channel='z', system=scanner_parameters, area=area_gz_spoil,
+                                   duration=spoil_duration)
+        gz_spoil2 = make_trapezoid(channel='z', system=scanner_parameters, area=area_gz_spoil,
+                                   duration=spoil_duration)
+
+    # SS refocusing gradient with spoilers
+
+    gz_sp1_times = np.array(
+        [
+            0,
+            gz_spoil1.rise_time,
+            gz_spoil1.rise_time + gz_spoil1.flat_time,
+            gz_spoil1.rise_time + gz_spoil1.flat_time + gz_spoil1.fall_time
+        ]
+    )
+    gz_sp1_amp = np.array(
+        [
+            0,
+            gz_spoil1.amplitude,
+            gz_spoil1.amplitude,
+            gz180.amplitude
+        ]
+    )
+    gz_sp1 = make_extended_trapezoid(channel='z', system=scanner_parameters, times=gz_sp1_times, amplitudes=gz_sp1_amp)
+
+    gz_sp2_times = np.array(
+        [
+            0,
+            gz180.flat_time
+        ]
+    )
+    gz_sp2_amp = np.array(
+        [
+            gz180.amplitude,
+            gz180.amplitude
+        ]
+    )
+    gz_sp2 = make_extended_trapezoid(channel='z', system=scanner_parameters, times=gz_sp2_times, amplitudes=gz_sp2_amp)
+
+    gz_sp3_times = np.array(
+        [
+            0,
+            gz_spoil2.rise_time,
+            gz_spoil2.rise_time + gz_spoil2.flat_time,
+            gz_spoil2.rise_time + gz_spoil2.flat_time + gz_spoil2.fall_time
+        ]
+    )
+
+    gz_sp3_amp = np.array(
+        [
+            gz180.amplitude,
+            gz_spoil2.amplitude,
+            gz_spoil2.amplitude,
+            0
+        ]
+    )
+    gz_sp3 = make_extended_trapezoid(channel='z', system=scanner_parameters, times=gz_sp3_times, amplitudes=gz_sp3_amp)
+
+    if united:
+        return rf180, gz_sp1, gz_sp2, gz_sp3
+    else:
+        return rf180, gz_spoil1, gz180, gz_spoil2
+
+def readout_grad(params, scanner_parameters, spoil_duration, united: bool):
+    # Create readout gradient
+    readout_time = round(1 / params['BW_pixel'], 8)
+    k_read = np.double(params['Nf']) / np.double(params['FoV_f'])
+    t_gx = np.ceil(readout_time / scanner_parameters.grad_raster_time) * scanner_parameters.grad_raster_time
+    gx = make_trapezoid(channel='x', system=scanner_parameters, flat_area=k_read,
+                        flat_time=t_gx)
+
+    # generate gx spoiler gradient - G_crr
+    if spoil_duration == 'min':
+        gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area,
+                                  flat_time=params['dG'], rise_time=params['dG'])
+    else:
+        spoil_duration = float(spoil_duration)
+        spoil_duration = (np.ceil(spoil_duration / scanner_parameters.grad_raster_time)
+                          * scanner_parameters.grad_raster_time)
+        gx_spoil = make_trapezoid(channel='x', system=scanner_parameters, area=gx.area,
+                                  duration=spoil_duration)
+
+    # readout gradient with spoilers
+
+    gx_sp1_times = np.array(
+        [
+            0,
+            gx_spoil.rise_time,
+            gx_spoil.rise_time + gx_spoil.flat_time,
+            gx_spoil.rise_time + gx_spoil.flat_time + gx_spoil.fall_time
+        ]
+    )
+    gx_sp1_amp = np.array(
+        [
+            0,
+            gx_spoil.amplitude,
+            gx_spoil.amplitude,
+            gx.amplitude
+        ]
+    )
+    gx_sp1 = make_extended_trapezoid(channel='x', system=scanner_parameters, times=gx_sp1_times, amplitudes=gx_sp1_amp)
+
+    gx_sp2_times = np.array(
+        [
+            0,
+            gx.flat_time
+        ]
+    )
+    gx_sp2_amp = np.array(
+        [
+            gx.amplitude,
+            gx.amplitude
+        ]
+    )
+    gx_sp2 = make_extended_trapezoid(channel='x', system=scanner_parameters, times=gx_sp2_times, amplitudes=gx_sp2_amp)
+
+    gx_sp3_times = np.array(
+        [
+            0,
+            gx_spoil.rise_time,
+            gx_spoil.rise_time + gx_spoil.flat_time,
+            gx_spoil.rise_time + gx_spoil.flat_time + gx_spoil.fall_time
+        ]
+    )
+
+    gx_sp3_amp = np.array(
+        [
+            gx.amplitude,
+            gx_spoil.amplitude,
+            gx_spoil.amplitude,
+            0
+        ]
+    )
+    gx_sp3 = make_extended_trapezoid(channel='x', system=scanner_parameters, times=gx_sp3_times, amplitudes=gx_sp3_amp)
+
+    if united:
+        return gx_sp1, gx_sp2, gx_sp3
+    else:
+        return gx_spoil, gx, gx_spoil

+ 0 - 0
libs/lf-scanner/pypulseq/utils/SAR/__init__.py


+ 0 - 0
libs/lf-scanner/pypulseq/utils/__init__.py


+ 15 - 0
libs/lf-scanner/pypulseq/utils/cumsum.py

@@ -0,0 +1,15 @@
+def cumsum(a, b, c=None, d=None, e=None):
+    if e != None:
+        s1 = a + b
+        s2 = s1 + c
+        s3 = s2 + d
+        return (a, s1, s2, s3, s3 + e)
+    elif d != None:
+        s1 = a + b
+        s2 = s1 + c
+        return (a, s1, s2, s2 + d)
+    elif c != None:
+        s = a + b
+        return (a, s, s + c)
+    else:
+        return (a, a + b)

+ 411 - 0
libs/lf-scanner/pypulseq/utils/safe_pns_prediction.py

@@ -0,0 +1,411 @@
+# This code is a direct Python translation of the relevant functions in
+# https://github.com/filip-szczepankiewicz/safe_pns_prediction/ to perform
+# PNS calculations with pypulseq
+#
+# A small modification was made to safe_plot to plot long sequences better
+
+
+# BSD 3-Clause License
+
+# Copyright (c) 2018, Filip Szczepankiewicz and Thomas Witzel
+# All rights reserved.
+
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+
+# 1. Redistributions of source code must retain the above copyright notice, this
+#    list of conditions and the following disclaimer.
+
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+
+# 3. Neither the name of the copyright holder nor the names of its
+#    contributors may be used to endorse or promote products derived from
+#    this software without specific prior written permission.
+
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from types import SimpleNamespace
+
+import numpy as np
+import matplotlib.pyplot as plt
+
+
+def safe_example_hw():
+    # function hw = safe_example_hw()
+    #
+    # SAFE model parameters for EXAMPLE scanner hardware (not a real scanner).
+    # See comments for units.
+    
+    hw = SimpleNamespace()
+    hw.name          = 'MP_GPA_EXAMPLE'
+    hw.checksum      = '1234567890'
+    hw.dependency    = ''
+    
+    hw.x = SimpleNamespace()
+    hw.x.tau1        =  0.20  # ms
+    hw.x.tau2        =  0.03  # ms
+    hw.x.tau3        =  3.00  # ms
+    hw.x.a1          =  0.40
+    hw.x.a2          =  0.10
+    hw.x.a3          =  0.50
+    hw.x.stim_limit  = 30.0   # T/m/s
+    hw.x.stim_thresh = 24.0   # T/m/s
+    hw.x.g_scale     = 0.35   # 1
+    
+    hw.y = SimpleNamespace()
+    hw.y.tau1        =  1.50  # ms
+    hw.y.tau2        =  2.50  # ms
+    hw.y.tau3        =  0.15  # ms
+    hw.y.a1          =  0.55
+    hw.y.a2          =  0.15
+    hw.y.a3          =  0.30
+    hw.y.stim_limit  = 15.0   # T/m/s
+    hw.y.stim_thresh = 12.0   # T/m/s
+    hw.y.g_scale     = 0.31   # 1
+    
+    hw.z = SimpleNamespace()
+    hw.z.tau1        =  2.00  # ms
+    hw.z.tau2        =  0.12  # ms
+    hw.z.tau3        =  1.00  # ms
+    hw.z.a1          =  0.42
+    hw.z.a2          =  0.40
+    hw.z.a3          =  0.18
+    hw.z.stim_limit  = 25.0   # T/m/s
+    hw.z.stim_thresh = 20.0   # T/m/s
+    hw.z.g_scale     = 0.25   # 1
+    return hw
+
+
+def safe_example_gwf():
+    # function function [gwf, rf, dt] = safe_example_gwf()
+    # Waveform with some frequency matching by Filip Szczepankiewicz.
+    #
+    # Waveform was optimized in the NOW framework by Jens Sjölund et al.
+    # https://github.com/jsjol/NOW
+    #
+    # Optimization was Maxwell-compensated to remove effects of concomitant
+    # gradients.
+    # https://arxiv.org/ftp/arxiv/papers/1903/1903.03357.pdf
+    
+    ## STE
+    dt  = 1e-3 # ms
+    
+    # T/m
+    gwf = 0.08 * np.array([
+        [0,         0,         0],
+        [-0.2005,    0.9334,    0.3029],
+        [-0.2050,    0.9324,    0.3031],
+        [-0.2146,    0.9302,    0.3032],
+        [-0.2313,    0.9263,    0.3030],
+        [-0.2589,    0.9193,    0.3019],
+        [-0.3059,    0.9060,    0.2980],
+        [-0.3892,    0.8767,    0.2883],
+        [-0.3850,    0.7147,    0.3234],
+        [-0.3687,    0.5255,    0.3653],
+        [-0.3509,    0.3241,    0.4070],
+        [-0.3323,    0.1166,    0.4457],
+        [-0.3136,   -0.0906,    0.4783],
+        [-0.2956,   -0.2913,    0.5019],
+        [-0.2790,   -0.4793,    0.5139],
+        [-0.2642,   -0.6491,    0.5118],
+        [-0.2518,   -0.7957,    0.4939],
+        [-0.2350,   -0.8722,    0.4329],
+        [-0.2187,   -0.9111,    0.3541],
+        [-0.2063,   -0.9409,    0.2747],
+        [-0.1977,   -0.9627,    0.1933],
+        [-0.1938,   -0.9768,    0.1080],
+        [-0.1967,   -0.9820,    0.0159],
+        [-0.2114,   -0.9751,   -0.0883],
+        [-0.2292,   -0.9219,   -0.2150],
+        [-0.2299,   -0.8091,   -0.3561],
+        [-0.2290,   -0.6748,   -0.5011],
+        [-0.2253,   -0.5239,   -0.6460],
+        [-0.2178,   -0.3620,   -0.7868],
+        [-0.2056,   -0.1948,   -0.9194],
+        [-0.1391,   -0.0473,   -0.9908],
+        [-0.0476,    0.0607,   -0.9987],
+        [ 0.0215,    0.1452,   -0.9909],
+        [ 0.0725,    0.2136,   -0.9759],
+        [ 0.1114,    0.2709,   -0.9579],
+        [ 0.1426,    0.3204,   -0.9383],
+        [ 0.1690,    0.3641,   -0.9177],
+        [ 0,         0,         0],
+        [ 0,         0,         0],
+        [ 0,         0,         0],
+        [ 0,         0,         0],
+        [ 0,         0,         0],
+        [ 0,         0,         0],
+        [ 0,         0,         0],
+        [-0.3734,   -0.1768,    0.9125],
+        [-0.3825,   -0.2310,    0.8965],
+        [-0.3919,   -0.2895,    0.8752],
+        [-0.4015,   -0.3543,    0.8465],
+        [-0.4108,   -0.4290,    0.8065],
+        [-0.4182,   -0.5202,    0.7469],
+        [-0.4178,   -0.6423,    0.6451],
+        [-0.3855,   -0.8173,    0.4321],
+        [-0.3110,   -0.9418,    0.1401],
+        [-0.2526,   -0.9669,   -0.0674],
+        [-0.2100,   -0.9541,   -0.2213],
+        [-0.1766,   -0.9227,   -0.3474],
+        [-0.1491,   -0.8788,   -0.4570],
+        [-0.1258,   -0.8239,   -0.5555],
+        [-0.1056,   -0.7583,   -0.6459],
+        [-0.0882,   -0.6809,   -0.7293],
+        [-0.0734,   -0.5900,   -0.8061],
+        [-0.0615,   -0.4830,   -0.8753],
+        [-0.0533,   -0.3556,   -0.9349],
+        [-0.0506,   -0.2005,   -0.9801],
+        [-0.0575,   -0.0019,   -1.0000],
+        [-0.0909,    0.2976,   -0.9521],
+        [-0.3027,    0.9509,   -0.0860],
+        [-0.2737,    0.9610,   -0.0692],
+        [-0.2524,    0.9675,   -0.0596],
+        [-0.2364,    0.9719,   -0.0533],
+        [-0.2245,    0.9749,   -0.0490],
+        [-0.2158,    0.9770,   -0.0459],
+        [-0.2097,    0.9785,   -0.0439],
+        [-0.2058,    0.9794,   -0.0426],
+        [-0.2039,    0.9798,   -0.0420],
+        [ 0,         0,         0]
+        ])
+    
+    rf = np.ones(gwf.shape[0])
+    rf[40:] = -1
+    
+    return gwf, rf, dt
+
+
+def safe_hw_check(hw):
+    # function safe_hw_check(hw)
+    #
+    # Make sure that all is well with the hardware configuration.
+    
+    if abs(hw.x.a1 + hw.x.a2 + hw.x.a3 - 1) > 0.001 or \
+       abs(hw.y.a1 + hw.y.a2 + hw.y.a3 - 1) > 0.001 or \
+       abs(hw.z.a1 + hw.z.a2 + hw.z.a3 - 1) > 0.001:
+        raise ValueError('Hardware specification a1+a2+a3 must be equal to 1!')
+    
+    axl = ['x', 'y', 'z']
+    fnl = ['stim_limit', 'stim_thresh', 'tau1', 'tau2', 'tau3', 'a1', 'a2', 'a3', 'g_scale']
+    
+    for axn in axl:
+        if not hasattr(hw, axn):
+            raise ValueError(f"'{axn}' missing in hardware specification")
+        
+        hw_ax = getattr(hw, axn)
+        for par in fnl:
+            if not hasattr(hw_ax, par):
+                raise ValueError(f"'{axn}.{par}' missing in hardware specification")
+
+
+def safe_longest_time_const(hw):
+    # function ltau = safe_longest_time_const(hw)
+    # Get the longest time constant. Can be used to estimate the size of zero
+    # padding.
+
+    return max([hw.x.tau1, hw.x.tau2, hw.x.tau3,
+                hw.y.tau1, hw.y.tau2, hw.y.tau3,
+                hw.z.tau1, hw.z.tau2, hw.z.tau3])
+
+
+def safe_pns_model(dgdt, dt, hw):
+    # function stim = safe_pns_model(dgdt, dt, hw)
+    #
+    # dgdt (nx3) is in T/m/s
+    # dt   (1x1) is in s
+    # All time coefficients (a1 and tau1 etc.) are in ms.
+    #
+    # This PNS model is based on the SAFE-abstract
+    # SAFE-Model - A New Method for Predicting Peripheral Nerve Stimulations in MRI
+    # by Franz X. Herbank and Matthias Gebhardt. Abstract No 2007. 
+    # Proc. Intl. Soc. Mag. Res. Med. 8, 2000, Denver, Colorado, USA
+    # https://cds.ismrm.org/ismrm-2000/PDF7/2007.PDF
+    # 
+    # The main SAFE-model was coded by Thomas Witzel @ Martinos Center,
+    # MGH, HMS, Boston, MA, USA.
+    # 
+    # The code was adapted/expanded/corrected by Filip Szczepankiewicz @ LMI
+    # BWH, HMS, Boston, MA, USA, and Lund University, Sweden.
+    
+    stim1 = hw.a1 * abs( safe_tau_lowpass(dgdt     , hw.tau1, dt * 1000) )
+    stim2 = hw.a2 *      safe_tau_lowpass(abs(dgdt), hw.tau2, dt * 1000)  
+    stim3 = hw.a3 * abs( safe_tau_lowpass(dgdt     , hw.tau3, dt * 1000) )
+    
+    stim = (stim1 + stim2 + stim3) / hw.stim_limit * hw.g_scale * 100
+    
+    return stim
+    
+    # Not sure where something goes awry, probably in the lowpass filter, but
+    # compared to the Siemens simulator we are exactly a factor of pi off, so
+    # I'm dividing the final result by pi.
+    # Note also that the final result is essentially some kind of arbitrary
+    # unit. - TW
+    
+    # UPDATE 210720 - The pi factor was not quite correct. Instead, the correct
+    # factor was determined by the gradient scale factor (hw.g_scale, defined 
+    # in the .asc file). Thanks to Maxim Zaitsev for supporting this buggfix and 
+    # validating that the updated code is accurate. - FSz
+
+
+def safe_tau_lowpass(dgdt, tau, dt, eps=1e-16):
+    # function fw = safe_tau_lowpass(dgdt, tau, dt)
+    #
+    # Apply a RC lowpass filter with time constant tau = RC to data with sampling
+    # interval dt. NOTE tau and dt need to be in the same unit (i.e. s or ms)
+    # The SAFE model abstract by Hebrank et.al. just says "Lowpass with time-constant tau",
+    # so I decided to make the most simple filter possible here.
+    # The RC lowpass is also appealing because its something Siemens could have
+    # easily implemented on their hardware stimulation monitors, so I'm probably
+    # pretty close. - TW
+    #
+    # UPDATE 230206 - There was a factor alpha missing on the first sample it
+    # has now been corrected. Thanks to Oliver Schad for finding this error.
+    # - FSz
+    
+    alpha = dt / (tau + dt)
+    
+    # Calculate number of elements in filter to reach desired accuracy (eps)
+    n = min(round(np.log(eps) / np.log(1-alpha)), dgdt.shape[0])
+    filt = (1-alpha)**np.arange(n)
+
+    # Implements lowpass filter using convolution to get rid of for loop in original code
+    return alpha * np.convolve(dgdt, filt)[:dgdt.shape[0]]
+
+
+def safe_gwf_to_pns(gwf, rf, dt, hw, do_padding=True):
+    # function [pns, res] = safe_gwf_to_pns(gwf, rf, dt, hw, doPadding)
+    # 
+    # gwf (nx3) in T/m
+    # dt  (1x1) in s
+    # hw  (struct) is structure that describes the hardware configuration and PNS
+    # response. Example: hw = safe_example_hw().
+    # doPadding adds zeropadding based on the decay time.
+    #
+    # This PNS model is based on the SAFE-abstract
+    # SAFE-Model - A New Method for Predicting Peripheral Nerve Stimulations in MRI
+    # by Franz X. Herbank and Matthias Gebhardt. Abstract No 2007. 
+    # Proc. Intl. Soc. Mag. Res. Med. 8, 2000, Denver, Colorado, USA
+    # https://cds.ismrm.org/ismrm-2000/PDF7/2007.PDF
+    # 
+    # The main SAFE-model was coded by Thomas Witzel @ Martinos Center,
+    # MGH, HMS, Boston, MA, USA.
+    # 
+    # The code was adapted/expanded by Filip Szczepankiewicz @ LMI
+    # BWH, HMS, Boston, MA, USA.
+
+    if do_padding:
+        zpt = safe_longest_time_const(hw) * 4 / 1000 # s
+        pad1 = round(zpt/4/dt)
+        pad2 = round(zpt/1/dt)
+
+        gwf = np.pad(gwf, ((pad1, pad2), (0,0)))
+        rf = np.pad(rf, (pad1, pad2))
+
+    safe_hw_check(hw)
+    
+    dgdt = np.diff(gwf, axis=0) / dt
+    pns = np.zeros(dgdt.shape)
+    
+    pns[:,0] = safe_pns_model(dgdt[:,0], dt, hw.x)
+    pns[:,1] = safe_pns_model(dgdt[:,1], dt, hw.y)
+    pns[:,2] = safe_pns_model(dgdt[:,2], dt, hw.z)
+    
+    # Export relevant paramters
+    res = SimpleNamespace()
+    res.pns  = pns
+    res.gwf  = gwf
+    res.rf   = rf
+    res.dgdt = dgdt
+    res.dt   = dt
+    res.hw   = hw
+    
+    return pns, res
+
+def safe_plot(pns, dt=None, envelope=True, envelope_points=500):
+    # function h = safe_plot(pns, dt)
+    # pns is relative PNS waveform (nx3)
+    # dt is time step size in seconds.
+        
+    pnsnorm = np.sqrt((pns**2).sum(axis=1))
+    
+    # FZ: Added option to plot the moving maximum of pns and pnsnorm to keep
+    #     plots for long sequences intelligible
+    if envelope and pns.shape[0] > envelope_points:
+        N = int(np.ceil(pns.shape[0] / envelope_points))
+        if dt != None:
+            dt *= N
+        
+        if pns.shape[0] % N != 0:
+            pns = np.concatenate((pns, np.zeros((N - pns.shape[0] % N, pns.shape[1]))))
+            pnsnorm = np.concatenate((pnsnorm, np.zeros((N - pnsnorm.shape[0] % N))))
+
+        pns = pns.reshape(pns.shape[0]//N, N, pns.shape[1])
+        pns = pns.max(axis=1)
+        pnsnorm = pnsnorm.reshape(pnsnorm.shape[0]//N, N)
+        pnsnorm = pnsnorm.max(axis=1)
+        
+    if dt == None:
+        ttot    = 1 # au
+        xlabstr = 'Time [a.u.]'
+    else:
+        ttot = pns.shape[0] * dt * 1000 # ms
+        xlabstr = 'Time [ms]'
+
+    
+    t = np.linspace(0, ttot, pns.shape[0])
+        
+    plt.plot(t, pns[:,0], 'r-',
+             t, pns[:,1], 'g-',
+             t, pns[:,2], 'b-',
+             t, pnsnorm , 'k-')
+        
+    plt.ylim([0, 120])
+    plt.xlim([min(t), max(t)])
+    
+    plt.title(f'Predicted PNS ({max(pnsnorm):0.0f}%)')
+    
+    plt.xlabel(xlabstr)
+    plt.ylabel('Relative stimulation [%]')
+    
+    plt.plot([0, max(t)], [max(pnsnorm), max(pnsnorm)], 'k:')
+
+    plt.legend([f'X ({max(pns[:,0]):0.0f}%)',
+                f'Y ({max(pns[:,1]):0.0f}%)',
+                f'Z ({max(pns[:,2]):0.0f}%)',
+                f'nrm ({max(pnsnorm):0.0f}%)'], loc='best')
+
+
+def safe_example():
+    # Load an exampe gradient waveform
+    [gwf, rf, dt] = safe_example_gwf()
+    
+    # Load reponse parameters for example hardware
+    hw = safe_example_hw()
+    
+    # Check if hardware parameters are consistent
+    safe_hw_check(hw)
+    
+    # Check if this hw is part of the library (validate hw)
+    # safe_hw_verify(hw)
+    
+    # Predict PNS levels
+    pns, res = safe_gwf_to_pns(gwf, rf, dt, hw, 1)
+    
+    # Plot some results
+    safe_plot(pns, dt)
+
+
+if __name__ == '__main__':
+    safe_example()

+ 1 - 0
libs/lf-scanner/pypulseq/utils/siemens/__init__.py

@@ -0,0 +1 @@
+

+ 105 - 0
libs/lf-scanner/pypulseq/utils/siemens/asc_to_hw.py

@@ -0,0 +1,105 @@
+from types import SimpleNamespace
+from typing import List
+import numpy as np
+
+
+def asc_to_acoustic_resonances(asc : dict) -> List[dict]:
+    """
+    Convert ASC dictionary from readasc to list of acoustic resonances
+
+    Parameters
+    ----------
+    asc : dict
+        ASC dictionary, see readasc
+
+    Returns
+    -------
+    List[dict]
+        List of acoustic resonances (specified by frequency and bandwidth fields).
+    """
+    
+    if 'aflGCAcousticResonanceFrequency' in asc:
+        freqs = asc['aflGCAcousticResonanceFrequency']
+        bw = asc['aflGCAcousticResonanceBandwidth']
+    else:
+        freqs = asc['asGPAParameters'][0]['sGCParameters']['aflAcousticResonanceFrequency']
+        bw = asc['asGPAParameters'][0]['sGCParameters']['aflAcousticResonanceBandwidth']
+    
+    return [dict(frequency=f, bandwidth=b) for f,b in zip(freqs.values(), bw.values()) if f != 0]
+
+def asc_to_hw(asc : dict, cardiac_model : bool = False) -> SimpleNamespace:
+    """
+    Convert ASC dictionary from readasc to SAFE hardware description.
+
+    Parameters
+    ----------
+    asc : dict
+        ASC dictionary, see readasc
+    cardiac_model : bool
+        Whether or not to read the cardiac stimulation model instead of the
+        default PNS model (returns None if not available)
+
+    Returns
+    -------
+    SimpleNamespace
+        SAFE hardware description
+    """
+    hw = SimpleNamespace()
+    
+    if 'asCOMP' in asc and 'tName' in asc['asCOMP']:
+        hw.name = asc['asCOMP']['tName']
+    else:
+        hw.name = 'unknown'
+
+    if 'GradPatSup' in asc:
+        asc_pns = asc['GradPatSup']['Phys']['PNS']
+    else:
+        asc_pns = asc
+    
+    if cardiac_model:
+        if 'GradPatSup' in asc and 'CarNS' in asc['GradPatSup']['Phys']:
+            asc_pns = asc['GradPatSup']['Phys']['CarNS']
+        else:
+            return None
+
+    hw.x = SimpleNamespace()
+    hw.x.tau1        = asc_pns['flGSWDTauX'][0]  # ms
+    hw.x.tau2        = asc_pns['flGSWDTauX'][1]  # ms
+    hw.x.tau3        = asc_pns['flGSWDTauX'][2]  # ms
+    hw.x.a1          = asc_pns['flGSWDAX'][0]
+    hw.x.a2          = asc_pns['flGSWDAX'][1]
+    hw.x.a3          = asc_pns['flGSWDAX'][2]
+    hw.x.stim_limit  = asc_pns['flGSWDStimulationLimitX']  # T/m/s
+    hw.x.stim_thresh = asc_pns['flGSWDStimulationThresholdX']  # T/m/s
+    
+    hw.y = SimpleNamespace()
+    hw.y.tau1        = asc_pns['flGSWDTauY'][0]  # ms
+    hw.y.tau2        = asc_pns['flGSWDTauY'][1]  # ms
+    hw.y.tau3        = asc_pns['flGSWDTauY'][2]  # ms
+    hw.y.a1          = asc_pns['flGSWDAY'][0]
+    hw.y.a2          = asc_pns['flGSWDAY'][1]
+    hw.y.a3          = asc_pns['flGSWDAY'][2]
+    hw.y.stim_limit  = asc_pns['flGSWDStimulationLimitY']  # T/m/s
+    hw.y.stim_thresh = asc_pns['flGSWDStimulationThresholdY']  # T/m/s
+    
+    hw.z = SimpleNamespace()
+    hw.z.tau1        = asc_pns['flGSWDTauZ'][0]  # ms
+    hw.z.tau2        = asc_pns['flGSWDTauZ'][1]  # ms
+    hw.z.tau3        = asc_pns['flGSWDTauZ'][2]  # ms
+    hw.z.a1          = asc_pns['flGSWDAZ'][0]
+    hw.z.a2          = asc_pns['flGSWDAZ'][1]
+    hw.z.a3          = asc_pns['flGSWDAZ'][2]
+    hw.z.stim_limit  = asc_pns['flGSWDStimulationLimitZ']  # T/m/s
+    hw.z.stim_thresh = asc_pns['flGSWDStimulationThresholdZ']  # T/m/s
+    
+    if 'asGPAParameters' in asc:
+        hw.x.g_scale     = asc['asGPAParameters'][0]['sGCParameters']['flGScaleFactorX']
+        hw.y.g_scale     = asc['asGPAParameters'][0]['sGCParameters']['flGScaleFactorY']
+        hw.z.g_scale     = asc['asGPAParameters'][0]['sGCParameters']['flGScaleFactorZ']
+    else:
+        print('Warning: Gradient scale factors not in ASC file: assuming 1/pi')
+        hw.x.g_scale = 1/np.pi
+        hw.y.g_scale = 1/np.pi
+        hw.z.g_scale = 1/np.pi
+    
+    return hw

+ 97 - 0
libs/lf-scanner/pypulseq/utils/siemens/readasc.py

@@ -0,0 +1,97 @@
+import re
+from typing import Tuple
+
+def readasc(filename : str) -> Tuple[dict, dict]:
+    """
+    Reads Siemens ASC ascii-formatted textfile and returns a dictionary
+    structure.
+    E.g. a[0].b[2][3].c = "string"
+    parses into:
+      asc['a'][0]['b'][2][3]['c'] = "string"
+
+    Parameters
+    ----------
+    filename : str
+        Filename of the ASC file.
+
+    Returns
+    -------
+    asc : dict
+        Dictionary of ASC part of file.
+    extra : dict
+        Dictionary of other fields after "ASCCONV END"
+    """
+    
+    asc, extra = {}, {}
+    
+    # Read asc file and convert it into a dictionary structure
+    with open(filename, 'r') as fp:
+        end_of_asc = False
+        
+        for next_line in fp:
+            next_line = next_line.strip()
+            
+            if next_line == '### ASCCONV END ###': # find end of mrProt in the asc file
+                end_of_asc = True
+    
+            if next_line == '' or next_line[0] == '#':
+                continue
+
+            # regex wizardry: Matches lines like 'a[0].b[2][3].c = "string" # comment'
+            # Note this assumes correct formatting, e.g. does not check whether
+            # brackets match.
+            match = re.match(r'^\s*([a-zA-Z0-9\[\]\._]+)\s*\=\s*(("[^"]*"|\'[^\']\')|(\d+)|([0-9\.e\-]+))\s*((#|\/\/)(.*))?$', next_line)
+    
+            if match:
+                field_name = match[1]
+
+                # Keep track of where to put the value: base[assign_to] = value
+                if end_of_asc:
+                    base = extra
+                else:
+                    base = asc
+
+                assign_to = None
+                
+                # Iterate over every segment of the field name
+                parts = field_name.split('.')
+                for p in parts:
+                    # Update base so final assignement is like: base[assign_to][p] = value
+                    if assign_to != None and assign_to not in base:
+                        base[assign_to] = {}
+                    if assign_to != None:
+                        base = base[assign_to]
+                    
+                    # Iterate over brackets
+                    start = p.find('[')
+                    if start != -1:
+                        name = p[:start]
+                        assign_to = name
+                        
+                        while start != -1:
+                            stop = p.find(']', start)
+                            index = int(p[start+1:stop])
+                            
+                            # Update base so final assignement is like: base[assign_to][p][index] = value
+                            if assign_to not in base:
+                                base[assign_to] = {}
+                            base = base[assign_to]
+                            assign_to = index
+                            
+                            start = p.find('[', stop)
+                    else:
+                        assign_to = p
+
+                # Depending on which regex section matched we can infer the value type
+                if match[3]:
+                    base[assign_to] = match[3][1:-1]
+                elif match[4]:
+                    base[assign_to] = int(match[4])
+                elif match[5]:
+                    base[assign_to] = float(match[5])
+                else:
+                    raise RuntimeError('This should not be reached')
+            elif next_line.find('=') != -1:
+                raise RuntimeError(f'Bug: ASC line with an assignment was not parsed correctly: {next_line}')
+
+    return asc, extra

BIN
libs/lf-scanner/rf_1.h5


BIN
libs/lf-scanner/rf_2.h5


BIN
libs/lf-scanner/rf_3.h5


BIN
libs/lf-scanner/rf_4.h5


BIN
libs/lf-scanner/rf_5.h5


BIN
libs/lf-scanner/rf_6.h5


BIN
libs/lf-scanner/rf_7.h5


BIN
libs/lf-scanner/rf_8.h5


+ 0 - 0
libs/lf-scanner/services/Protocol/__init__.py


+ 14 - 0
libs/lf-scanner/services/Protocol/protocol.py

@@ -0,0 +1,14 @@
+class Protocol:
+    def __init__(self, seq_number, name):
+        self.seq_number = seq_number
+        self.name = name
+
+    def add_sequence(self):
+        return 0
+
+    def delete_sequence(self):
+        return 0
+
+    def interp_sequence(self):
+        return 0
+

+ 4 - 0
libs/lf-scanner/services/__init__.py

@@ -0,0 +1,4 @@
+# =========
+# PACKAGE-LEVEL IMPORTS
+# =========
+from LF_scanner.services import srv_stack

+ 348 - 0
libs/lf-scanner/services/srv_interp.py

@@ -0,0 +1,348 @@
+# -*- coding: utf-8 -*-
+"""
+Created on 05/09/2024
+
+@author: spacexer
+"""
+from LF_scanner import pypulseq as pp
+import numpy as np
+from types import SimpleNamespace
+import json
+from yattag import Doc, indent
+
+
+def seq_file_input(seq_file_name="empty.seq"):
+    seq_input = pp.Sequence()
+    seq_input.read(file_path=seq_file_name)
+    seq_output_dict = seq_input.waveforms_export()
+    return seq_input, seq_output_dict
+
+
+def output_seq(dict, param, path='test1/'):
+    """
+    The interpretation from pypulseq format of sequence to the files needed to analog part of MRI
+
+    :param dict: Dictionary of the impulse sequence pypulseq provided
+
+    :return: files in "grad_output/" directory of every type of amplitudes and time points
+
+    """
+    '''
+    Gradient
+    '''
+    loc_t_gx = gradient_time_convertation(param, dict['t_gx'])
+    loc_t_gy = gradient_time_convertation(param, dict['t_gy'])
+    loc_t_gz = gradient_time_convertation(param, dict['t_gz'])
+    loc_gx = gradient_ampl_convertation(param, dict['gx'])
+    loc_gy = gradient_ampl_convertation(param, dict['gy'])
+    loc_gz = gradient_ampl_convertation(param, dict['gz'])
+    gx_out = duplicates_delete(np.transpose([loc_t_gx, loc_gx]))
+    gy_out = duplicates_delete(np.transpose([loc_t_gy, loc_gy]))
+    gz_out = duplicates_delete(np.transpose([loc_t_gz, loc_gz]))
+    np.savetxt(path + 'gx.txt', gx_out, fmt='%10.0f')
+    np.savetxt(path + 'gy.txt', gy_out, fmt='%10.0f')
+    np.savetxt(path + 'gz.txt', gz_out, fmt='%10.0f')
+    '''
+    Radio
+    '''
+    rf_raster_local = param['rf_raster_time']
+    rf_out = radio_ampl_convertation(dict["rf"], rf_raster=rf_raster_local)
+    file_rf = open(path + 'rf_' + str(rf_raster_local) + '_raster.bin', "wb")
+    for byte in rf_out:
+        file_rf.write(byte.to_bytes(1, byteorder='big', signed=1))
+    file_rf.close()
+
+    '''
+    for radiofreq tests
+    '''
+    # np.savetxt(path + 'rf_time.txt', np.transpose(dict["t_rf"]))
+    # np.savetxt(path + 'rf_ampl.txt', np.transpose(dict["rf"]))
+    # plt.plot(dict["t_rf"][0:2000], np.real(dict["rf"][0:2000]), label="real")
+    # plt.plot(dict["t_rf"][0:2000], np.imag(dict["rf"][0:2000]), label="image")
+    # plt.legend()
+    # plt.show()
+
+
+def radio_ampl_convertation(rf_ampl, rf_raster=1e-6):
+    #TODO: sampling resize to raster different with seqgen
+    out_rf_list = []
+    rf_ampl_raster = 127
+    rf_ampl_maximum = np.abs(max(rf_ampl))
+    proportional_cf_rf = rf_ampl_raster / rf_ampl_maximum
+    for rf_iter in range(len(rf_ampl)):
+        out_rf_list.append(round(rf_ampl[rf_iter].real * proportional_cf_rf))
+        out_rf_list.append(round(rf_ampl[rf_iter].imag * proportional_cf_rf))
+    return out_rf_list
+
+
+def duplicates_delete(loc_list):
+    new_list = [[0] * 2]
+    for i in range(len(loc_list)):
+        if loc_list[i][0] not in np.transpose(new_list)[0]:
+            new_list.append(loc_list[i])
+    return new_list
+
+
+def gradient_time_convertation(param_loc, time_sample):
+    g_raster_time = param_loc['grad_raster_time']
+    time_sample /= g_raster_time
+    return time_sample
+
+
+def gradient_ampl_convertation(param, gradient_herz):
+    """
+    Helper function that convert amplitudes to dimensionless format for machine
+    1 bit for sign, 15 bits of numbers
+
+    :param gradient_herz: 2D array of amplitude and time points in Hz/m
+
+    :return: gradient_dimless: 2D array of dimensionless points
+
+    """
+    # amplitude raster is 32768
+    # maximum grad = 10 mT/m
+    # artificial gap is 1 mT/m so 9 mT/m is now should be split in parts
+    amplitude_max = param['G_amp_max']
+    amplitude_raster = 32767
+    step_Hz_m = amplitude_max / amplitude_raster  # Hz/m step gradient
+    gradient_dimless = gradient_herz / step_Hz_m * 1000
+    # assert abs(any(gradient_dimless)) > 32768, 'Amplitude is higher than expected, check the rate number'
+    return gradient_dimless
+
+
+def adc_correction(blocks_number_loc, seq_input_loc):
+    """
+    Helper function that rise times for correction of ADC events
+    Вспомогательная функция получения времён для коррекции АЦП событий
+    :return:    rise_time: float, stores in pulseq, related to exact type of gradient events
+                    хранится в pulseq, связан с конкретным типом градиентного события
+                fall_time: float, same as rise_time
+                    аналогично rise_time
+    """
+    rise_time, fall_time = None, None
+    is_adc_inside = False
+    for j in range(blocks_number_loc - 1):
+        iterable_block = seq_input_loc.get_block(block_index=j + 1)
+        if iterable_block.adc is not None:
+            is_adc_inside = True
+            rise_time = iterable_block.gx.rise_time
+            fall_time = iterable_block.gx.fall_time
+    if not is_adc_inside:
+        raise Exception("No ADC event found inside sequence")
+    return rise_time, fall_time
+
+
+def adc_event_edges(local_gate_adc):
+    """
+    Helper function that rise numbers of blocks of border  correction of ADC events
+    Вспомогательная функция для получения номеров блоков границ коррекции АЦП событий
+    :return:    num_begin_l:    int, number of time block when adc event starts
+                                номер временного блока начала АЦП события
+                num_finish_l:   int, same but ends
+                                то же, но для окончания
+    """
+    num_begin_l = 0
+    flag_begin = False
+    flag_finish = False
+    num_finish_l = 1
+    for k in range(len(local_gate_adc) - 1):
+        if local_gate_adc[k] != 0 and not flag_begin:
+            num_begin_l = k
+            flag_begin = True
+        if local_gate_adc[k] != 0 and local_gate_adc[k + 1] == 0 and not flag_finish:
+            num_finish_l = k
+            flag_finish = True
+    return num_begin_l, num_finish_l
+
+
+def synchronization(sync_sequence, synchro_block_timer=20e-9, path='test1/', TR_DELAY_L=800e-9, RF_DELAY_L=800e-9,
+                    START_DELAY_L=800e-9):
+    ### MAIN LOOP ###
+    ### ОСНОВНОЙ ЦИКЛ###
+    MIN_BLOCK_TIME = 400e-9
+    assert START_DELAY_L >= RF_DELAY_L
+    assert TR_DELAY_L >= synchro_block_timer
+    assert RF_DELAY_L >= synchro_block_timer
+    number_of_blocks = len(sync_sequence.block_events)
+    gate_adc = [0]
+    gate_rf = [0] * CONST_HACK_RF_DELAY
+    gate_tr_switch = [1]
+    blocks_duration = [START_DELAY_L]
+    adc_times_values = []
+    adc_times_starts = []
+    '''
+    ID RF  GX  GY  GZ  ADC  EXT
+    0    1   2   3   4   5    6
+    '''
+    added_blocks = 0
+    for block_counter in range(number_of_blocks):
+        is_not_adc_block = True
+
+        if sync_sequence.block_events[block_counter + 1][5]:
+            is_not_adc_block = False
+
+            gate_adc.append(0)
+            gate_rf.append(gate_rf[-1])
+            blocks_duration[-1] -= TR_DELAY_L
+            blocks_duration.append(TR_DELAY_L)
+            gate_tr_switch.append(0)
+            added_blocks += 1
+
+            gate_adc.append(1)
+            gate_tr_switch.append(0)
+        else:
+            gate_tr_switch.append(1)
+            gate_adc.append(0)
+
+        if sync_sequence.block_events[block_counter + 1][1] and is_not_adc_block:
+            gate_rf.append(1)
+            gate_adc.append(gate_adc[-1])
+            blocks_duration[-1] -= RF_DELAY_L
+            blocks_duration.append(RF_DELAY_L)
+            gate_tr_switch.append(gate_tr_switch[-1])
+            added_blocks += 1
+
+            gate_rf.append(1)
+
+        else:
+            gate_rf.append(0)
+
+        current_block_dur = sync_sequence.block_durations[block_counter + 1]
+        blocks_duration.append(current_block_dur)
+
+    number_of_blocks += added_blocks
+    # gate_gx = [1] * number_of_blocks
+    # gate_gy = [1] * number_of_blocks
+    # gate_gz = [1] * number_of_blocks
+    '''
+    test1 swap
+    '''
+    # assert any(block_times) < MIN_BLOCK_TIME, "ERROR: events in the current sequence are less than 400 ns"
+
+    doc, tag, text = Doc().tagtext()
+    with tag('root'):
+        with tag('ParamCount'):
+            text(number_of_blocks)
+        with tag('RF'):
+            for RF_iter in range(number_of_blocks):
+                with tag('RF' + str(RF_iter + 1)):
+                    text(gate_rf[RF_iter])
+        with tag('SW'):
+            for SW_iter in range(number_of_blocks):
+                with tag('SW' + str(SW_iter + 1)):
+                    text(gate_tr_switch[SW_iter])
+        with tag('ADC'):
+            for ADC_iter in range(number_of_blocks):
+                if gate_adc[ADC_iter] == 1:
+                    adc_times_values.append(blocks_duration[ADC_iter])
+                    adc_times_starts.append(sum(blocks_duration[0:ADC_iter]))
+                with tag('ADC' + str(ADC_iter + 1)):
+                    text(gate_adc[ADC_iter])
+        with tag('GR'):
+            with tag('GR1'):
+                text(1)
+            for GX_iter in range(1, number_of_blocks):
+                with tag('GR'+ str(GX_iter + 1)):
+                    text(0)
+        with tag('CL'):
+            with tag('CL' + str(1)):
+                text(int(MIN_BLOCK_TIME / synchro_block_timer))
+            for CL_iter in range(1, number_of_blocks):
+                with tag('CL' + str(CL_iter + 1)):
+                    text(int(blocks_duration[CL_iter] / synchro_block_timer))
+
+    result = indent(
+        doc.getvalue(),
+        indentation=' ' * 4,
+        newline='\r'
+    )
+    sync_file = open(path + "sync_v2.xml", "w")
+    sync_file.write(result)
+    sync_file.close()
+
+    picoscope_set(adc_times_values, adc_times_starts)
+
+
+def picoscope_set(adc_val, adc_start, number_of_channels_l=8, sampling_freq_l=4e7, path='test1/'):
+    # sampling rate = 40 MHz = 4e7 1/s
+    # adc_val in seconds
+    adc_out_timings = []
+    for i in adc_val:
+        adc_out_timings.append(int(i * sampling_freq_l))
+
+    doc, tag, text, line = Doc().ttl()
+    with tag('root'):
+        with tag('points'):
+            with tag('title'):
+                text("Points")
+            with tag('value'):
+                text(str(adc_out_timings))
+        with tag('num_of_channels'):
+            with tag('title'):
+                text("Number of Channels")
+            with tag('value'):
+                text(number_of_channels_l)
+        with tag('times'):
+            with tag('title'):
+                text("Times")
+            with tag('value'):
+                text(str(adc_start).format('%.e'))
+        with tag('sample_freq'):
+            with tag('title'):
+                text("Sample Frequency")
+            with tag('value'):
+                text(sampling_freq_l)
+
+    result = indent(
+        doc.getvalue(),
+        indentation=' ' * 4,
+        newline='\r'
+    )
+    sync_file = open(path + "picoscope_params.xml", "w")
+    sync_file.write(result)
+    sync_file.close()
+
+
+if __name__ == "__main__":
+    CONST_HACK_RF_DELAY = 17 * 2 * 2
+    SEQ_INPUT, SEQ_DICT = seq_file_input(seq_file_name='sequences/turbo_FLASH_060924_0444.seq')
+    # SEQ_INPUT, SEQ_DICT = seq_file_input(seq_file_name='sequences/test1_full.seq')
+
+    params_path = 'sequences/'
+    params_filename = "turbo_FLASH_060924_0444"
+    # params_filename = "test1_full"
+
+    file = open(params_path + params_filename + ".json", 'r')
+    SEQ_PARAM = json.load(file)
+    file.close()
+
+    '''
+    integartion of srv_seq_gen
+    '''
+    # SEQ_PARAM = set_limits()
+    # SEQ_INPUT = save_param()
+    # SEQ_DICT = SEQ_INPUT.waveforms_export()
+    '''
+    simulation of inputing the JSON and SEQ
+    '''
+
+    # artificial delays due to construction of the MRI
+    # искусственные задержки из-за тех. особенностей МРТ
+    # RF_dtime = 10 * 1e-6
+    # TR_dtime = 10 * 1e-6
+
+    time_info = SEQ_INPUT.duration()
+    blocks_number = time_info[1]
+    time_dur = time_info[0]
+
+    # output interpretation. all formats of files defined in method
+    # интерпретация выхода. Все форматы файлов определены в методе
+    output_seq(SEQ_DICT, SEQ_PARAM)
+
+    # defining constants of the sequence
+    # определение констант последовательности
+    local_definitions = SEQ_INPUT.definitions
+    ADC_raster = local_definitions['AdcRasterTime']
+    RF_raster = local_definitions['RadiofrequencyRasterTime']
+
+    synchronization(SEQ_INPUT)

+ 0 - 0
libs/lf-scanner/services/srv_stack.py


+ 53 - 0
libs/lf-scanner/setup.py

@@ -0,0 +1,53 @@
+import setuptools
+
+from version import major, minor, revision
+
+
+def _get_long_description() -> str:
+    """
+    Returns long description from `README.md` if possible, else 'Pulseq in Python'.
+
+    Returns
+    -------
+    str
+        Long description of PyPulseq project.
+    """
+    try:  # Unicode decode error on Windows
+        with open("README.md", "r") as fh:
+            long_description = fh.read()
+    except:
+        long_description = "Pulseq in Python"
+    return long_description
+
+
+setuptools.setup(
+    author="Keerthi Sravan Ravi",
+    author_email="ks3621@columbia.edu",
+    classifiers=[
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "License :: OSI Approved :: GNU Affero General Public License v3",
+        "Operating System :: OS Independent",
+    ],
+    description="Pulseq in Python",
+    include_package_data=True,
+    install_requires=[
+        "coverage>=6.2",
+        "matplotlib>=3.5.2",
+        "numpy>=1.19.5",
+        "scipy>=1.8.1",
+        "sigpy==0.1.23",
+    ],
+    license="License :: OSI Approved :: GNU Affero General Public License v3",
+    long_description=_get_long_description(),
+    long_description_content_type="text/markdown",
+    name="pypulseq",
+    packages=setuptools.find_packages(),
+    py_modules=["version"],
+    # package_data for wheel distributions; MANIFEST.in for source distributions
+    package_data={"pypulseq.SAR": ["QGlobal.mat"]},
+    project_urls={"Documentation": "https://pypulseq.readthedocs.io/en/latest/"},
+    python_requires=">=3.6.3",
+    url="https://github.com/imr-framework/pypulseq",
+    version=".".join((str(major), str(minor), str(revision))),
+)

File diff suppressed because it is too large
+ 509 - 0
libs/lf-scanner/t1_SE.ipynb


File diff suppressed because it is too large
+ 626 - 0
libs/lf-scanner/t1_SE_experimental.ipynb


File diff suppressed because it is too large
+ 346 - 0
libs/lf-scanner/t1_SE_final.ipynb


File diff suppressed because it is too large
+ 477 - 0
libs/lf-scanner/t1_SE_final_final.ipynb


File diff suppressed because it is too large
+ 424 - 0
libs/lf-scanner/t1_SE_final_max_grad.ipynb


File diff suppressed because it is too large
+ 499 - 0
libs/lf-scanner/t2_SE_backup.ipynb


File diff suppressed because it is too large
+ 66 - 0
libs/lf-scanner/t2_SE_original.ipynb


File diff suppressed because it is too large
+ 0 - 0
libs/lf-scanner/t2_se_pypulseq_colab.xml


+ 0 - 0
libs/lf-scanner/utilities/__init__.py


+ 16 - 0
libs/lf-scanner/utilities/phase_grad_utils.py

@@ -0,0 +1,16 @@
+import numpy as np
+
+def create_k_steps(k_span, steps):
+    """
+    A function that returns a k_span gradient span with odd and even gradient steps
+    """
+    k_steps = np.array(range(steps+1))
+    
+    if (np.mod(steps,2) == 0):      
+        k_steps = ( k_steps - steps/2 ) / (steps/2)
+    else:
+        k_steps = ( k_steps - (steps+1)/2 ) / (steps/2)
+    
+    k_steps = np.flip(k_steps,0)
+    k_steps = np.delete(k_steps,-1)
+    return k_steps*k_span*0.5        

+ 5 - 0
libs/lf-scanner/version.py

@@ -0,0 +1,5 @@
+from typing import Union
+
+major: int = 1
+minor: int = 4
+revision: Union[int, str] = 0

File diff suppressed because it is too large
+ 448 - 0
libs/lf-scanner/write_se_new.ipynb


File diff suppressed because it is too large
+ 463 - 0
libs/lf-scanner/write_t2_se.ipynb


+ 18 - 0
services/orchestrator/Dockerfile

@@ -0,0 +1,18 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1
+
+RUN apt-get update && apt-get install -y --no-install-recommends curl \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+EXPOSE 1717
+
+CMD ["uvicorn", "orchestrator.main:app", "--host", "0.0.0.0", "--port", "1717"]

+ 0 - 0
services/orchestrator/orchestrator/__init__.py


+ 0 - 0
services/orchestrator/orchestrator/clients/__init__.py


+ 61 - 0
services/orchestrator/orchestrator/clients/reco_cl.py

@@ -0,0 +1,61 @@
+import requests
+from typing import Any, Dict
+
+class ReconstructorClient:
+    def __init__(self, base_url: str = "http://localhost:8081"):
+        self.base_url = base_url.rstrip("/")
+        self.timeout = 60
+
+    def reconstruct(self, body: Dict[str, Any], raw_format: str = "h5") -> Dict[str, Any]:
+        """
+        POST /h5/reconstruct или /reconstruct
+        body: {
+          "file_raw_id": "string",
+          "file_json_id": "string",
+          "file_order_id": "string",
+          "sequence_name": "string",
+          "digit": "string",
+          "phase_shift": bool
+        }
+        """
+        endpoint = "/h5/reconstruct" if raw_format == "h5" else "/reconstruct"
+        url = f"{self.base_url}{endpoint}"
+        r = requests.post(url, json=body, timeout=self.timeout)
+        r.raise_for_status()
+        return r.json()
+
+    def get_session(self, session_id: str) -> Dict[str, Any]:
+        """
+        GET /sessions/{session_id}
+        """
+        url = f"{self.base_url}/sessions/{session_id}"
+        r = requests.get(url, timeout=self.timeout)
+        r.raise_for_status()
+        return r.json()
+
+    def list_files(self, session_id: str) -> Dict[str, Any]:
+        """
+        GET /sessions/{session_id}/files
+        """
+        url = f"{self.base_url}/sessions/{session_id}/files"
+        r = requests.get(url, timeout=self.timeout)
+        r.raise_for_status()
+        return r.json()
+
+    def download_file(self, session_id: str, name: str) -> bytes:
+        """
+        GET /sessions/{session_id}/files/{name}
+        """
+        url = f"{self.base_url}/sessions/{session_id}/files/{name}"
+        r = requests.get(url, timeout=self.timeout)
+        r.raise_for_status()
+        return r.content
+
+    def download_archive(self, session_id: str) -> bytes:
+        """
+        GET /sessions/{session_id}/archive.zip
+        """
+        url = f"{self.base_url}/sessions/{session_id}/archive.zip"
+        r = requests.get(url, timeout=self.timeout)
+        r.raise_for_status()
+        return r.content

+ 109 - 0
services/orchestrator/orchestrator/clients/spec_cl.py

@@ -0,0 +1,109 @@
+
+import time
+import requests
+from requests.auth import HTTPBasicAuth
+from typing import Any, Dict, Optional, Union
+
+class SpectrometerClient:
+    def __init__(self, base_url: str = "http://localhost:8000",
+                 username: Optional[str] = None, password: Optional[str] = None,
+                 timeout_s: float = 30.0):
+        self.base = base_url.rstrip("/")
+        self.auth = HTTPBasicAuth(username, password) if username and password else None
+        self.timeout = timeout_s
+
+        # DRF root autodiscovery
+        idx = self._get_json(f"{self.base}/api/")
+        # коллекции с фоллбэками:
+        self.url_users_coll   = (idx.get("users")   or f"{self.base}/api/users/").rstrip("/")   + "/"
+        self.url_devices_coll = (idx.get("devices") or f"{self.base}/api/devices/").rstrip("/") + "/"
+        self.url_mparams_coll = (idx.get("mparams") or f"{self.base}/api/mparams/").rstrip("/") + "/"
+        self.url_mstate_coll  = (idx.get("mstate")  or f"{self.base}/api/mstate/").rstrip("/")  + "/"
+        self.url_mdata_coll   = (idx.get("mdata")   or f"{self.base}/api/mdata/").rstrip("/")   + "/"
+        self.url_measure_coll = (idx.get("measure") or f"{self.base}/api/measure/").rstrip("/") + "/"
+
+        self.last_response: Optional[requests.Response] = None
+
+    # --------------- high-level ---------------
+
+    def start_measurement(self, body: Dict[str, Any]) -> int:
+        """
+        POST /api/measure/
+        body: должен быть {"info": {...}}. Если пришли плоские поля — обернём.
+        Возвращает числовой id измерения.
+        """
+        if "info" not in body:
+            body = {"info": dict(body)}
+        res = self._post_json(self.url_measure_coll, body)
+        # Пытаемся извлечь id (разные реализации могут различаться)
+        meas_id = (
+            res.get("id")
+            or res.get("pk")
+            or res.get("uuid")
+            or res.get("measurement_id")
+            or (res.get("data") or {}).get("id")
+        )
+        if meas_id is None:
+            raise RuntimeError(f"Cannot determine measurement id from response: {res}")
+        try:
+            return int(meas_id)
+        except Exception:
+            # если id не числовой, пусть будет строкой, но клиент явно ошибку не кидает
+            # однако downstream ожидает число — лучше привести к ошибке здесь:
+            raise RuntimeError(f"Measurement id is not an integer: {meas_id!r}")
+
+    def get_state(self, meas_id: Union[int, str]) -> Dict[str, Any]:
+        # 1) detail: /api/measure/{id}/state/
+        url1 = f"{self.url_measure_coll}{meas_id}/state/"
+        r1 = self._get_any(url1)
+        if r1 is not None:
+            return r1
+        # 2) collection: /api/mstate/?measure={id}
+        return self._get_json(self.url_mstate_coll, params={"measure": meas_id})
+
+    def wait_data_ready(self, meas_id: Union[int, str], timeout_s: float = 120.0, poll_s: float = 1.0) -> Dict[str, Any]:
+        t0 = time.time()
+        last = {}
+        while True:
+            st = self.get_state(meas_id)
+            last = st
+            if bool(st.get("data_ready")) or st.get("status") in {"done", "failed", "error"}:
+                return st
+            if time.time() - t0 > timeout_s:
+                raise TimeoutError(f"Timeout waiting data_ready for measurement {meas_id}. Last state: {st}")
+            time.sleep(poll_s)
+
+    def get_data(self, meas_id: Union[int, str]) -> Dict[str, Any]:
+        # 1) detail: /api/measure/{id}/data/
+        url1 = f"{self.url_measure_coll}{meas_id}/data/"
+        r1 = self._get_any(url1)
+        if r1 is not None:
+            return r1
+        # 2) collection: /api/mdata/?measure={id}
+        return self._get_json(self.url_mdata_coll, params={"measure": meas_id})
+
+    # --------------- low-level ---------------
+
+    def _get_any(self, url: str) -> Optional[Dict[str, Any]]:
+        try:
+            r = requests.get(url, auth=self.auth, timeout=self.timeout)
+            if r.status_code == 404:
+                return None
+            r.raise_for_status()
+            self.last_response = r
+            return r.json()
+        except requests.HTTPError:
+            # если не 404 — пробрасываем
+            raise
+
+    def _get_json(self, url: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+        r = requests.get(url, params=params, auth=self.auth, timeout=self.timeout)
+        r.raise_for_status()
+        self.last_response = r
+        return r.json()
+
+    def _post_json(self, url: str, body: Dict[str, Any]) -> Dict[str, Any]:
+        r = requests.post(url, json=body, auth=self.auth, timeout=self.timeout)
+        r.raise_for_status()
+        self.last_response = r
+        return r.json()

+ 25 - 0
services/orchestrator/orchestrator/docstore.py

@@ -0,0 +1,25 @@
+import json
+from pathlib import Path
+from dataclasses import dataclass, asdict
+from typing import Any, Dict, Optional
+
+STORAGE_DIR = Path("storage")
+STORAGE_DIR.mkdir(exist_ok=True)
+
+@dataclass
+class JobDoc:
+    id: str
+    scenario: Dict[str, Any]
+
+    def save(self):
+        path = STORAGE_DIR / self.id / "job.json"
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text(json.dumps(asdict(self), ensure_ascii=False, indent=2), encoding="utf-8")
+
+    @staticmethod
+    def load(job_id: str) -> Optional["JobDoc"]:
+        path = STORAGE_DIR / job_id / "job.json"
+        if not path.exists():
+            return None
+        data = json.loads(path.read_text(encoding="utf-8"))
+        return JobDoc(**data)

+ 51 - 0
services/orchestrator/orchestrator/healthcheck.py

@@ -0,0 +1,51 @@
+"""
+Проверка, что все сервисы запущены
+"""
+
+import requests
+from requests.auth import HTTPBasicAuth
+
+SERVICES = [
+    {
+        "name": "Spectrometer",
+        "url": "http://localhost:8000/api/",
+        "auth": None,
+    },
+    {
+        "name": "Reconstructor",
+        "url": "http://localhost:8081/health",
+        "auth": ("admin", "admin"),
+    },
+    {
+        "name": "Orchestrator",
+        "url": "http://localhost:1717/scenario/list",
+        "auth": None,
+    },
+]
+
+
+def check_service(name, url, auth=None):
+    try:
+        r = requests.get(
+            url,
+            timeout=5,
+            auth=HTTPBasicAuth(*auth) if auth else None,
+        )
+        r.raise_for_status()
+        print(f"✅ {name} OK ({r.status_code})")
+        return True
+    except Exception as e:
+        print(f"❌ {name} FAIL: {e}")
+        return False
+
+
+if __name__ == "__main__":
+    all_ok = True
+    for service in SERVICES:
+        if not check_service(service["name"], service["url"], service["auth"]):
+            all_ok = False
+
+    if all_ok:
+        print("\nAll services are up ✅")
+    else:
+        print("\nSome services failed ❌")

+ 183 - 0
services/orchestrator/orchestrator/main.py

@@ -0,0 +1,183 @@
+import uuid
+import os
+from typing import Any, Dict, Optional
+from fastapi import FastAPI, HTTPException, Query
+from pydantic import BaseModel
+from .scenario import Scenario, Step, StepStatus
+from .docstore import JobDoc
+from .scenario_loader import load_scenarios
+from .signal_decoder import decode_measurement
+
+
+class LoadRequest(BaseModel):
+    param_overrides: Optional[Dict[str, Any]] = None
+    # Format: {step_name: {param_key: value}}
+    # Example: {"start_measurement": {"info": {...}}}
+
+# Выбор задач: боевой/заглушки
+mode = os.getenv("MODE", "stub")
+if mode == "real":
+    from . import tasks_real as tasks
+else:
+    from . import tasks_plug as tasks
+
+TASK_REGISTRY = tasks.TASK_REGISTRY
+
+app = FastAPI(title="Orchestrator with Templates")
+
+
+@app.get("/health")
+def health():
+    return {"status": "ok"}
+
+
+# Память для живых Scenario (по job_id)
+SCENARIOS: Dict[str, Scenario] = {}
+# Кэш шаблонов сценариев
+SCENARIO_TEMPLATES = load_scenarios()  # dict[id] = {"id": ..., "steps": [...]}
+
+
+def step_to_dict(s: Step) -> dict:
+    return {
+        "name": s.name,
+        "status": s.status.value if isinstance(s.status, StepStatus) else str(s.status),
+        "params": s.params,
+        "result": s.result,
+    }
+
+
+def build_scenario_from_template(tpl: dict) -> Scenario:
+    """
+    tpl: {"id": "...", "steps": [{"name": "...", "params": {...}}, ...]}
+    """
+    steps = []
+    for item in tpl.get("steps", []):
+        name = item["name"]
+        params = item.get("params", {}) or {}
+        if name not in TASK_REGISTRY:
+            raise HTTPException(status_code=400, detail=f"Unknown task name: {name}")
+        func = TASK_REGISTRY[name]
+        steps.append(Step(name=name, func=func, params=params))
+    return Scenario(steps=steps)
+
+
+@app.get("/scenario/list")
+def list_scenarios():
+    # перечень доступных шаблонов
+    return {"scenarios": list(SCENARIO_TEMPLATES.keys())}
+
+
+@app.post("/scenario/load/{scenario_id}")
+def load_scenario(scenario_id: str, body: LoadRequest = None):
+    tpl = SCENARIO_TEMPLATES.get(scenario_id)
+    if not tpl:
+        raise HTTPException(status_code=404, detail="Scenario template not found")
+
+    job_id = str(uuid.uuid4())
+    scenario = build_scenario_from_template(tpl)
+
+    # Apply per-step parameter overrides supplied by the caller (e.g. GUI info dict).
+    # This is additive: template params are kept unless the same key is overridden.
+    if body and body.param_overrides:
+        for step in scenario.steps:
+            if step.name in body.param_overrides:
+                step.params.update(body.param_overrides[step.name])
+
+    # сохраняем живой Scenario в память
+    SCENARIOS[job_id] = scenario
+
+    # создаём первичный JobDoc (pending шаги)
+    doc = JobDoc(
+        id=job_id,
+        scenario={"steps": [step_to_dict(s) for s in scenario.steps]},
+    )
+    doc.save()
+    return {"job_id": job_id}
+
+
+@app.post("/scenario/{job_id}/next")
+def run_next(job_id: str):
+    scenario = SCENARIOS.get(job_id)
+    if not scenario:
+        raise HTTPException(status_code=404, detail="Scenario not found")
+
+    if scenario.current >= len(scenario.steps):
+        raise HTTPException(status_code=400, detail="No more steps to run")
+
+    step = scenario.run_next()
+
+    # сохраняем прогресс
+    doc = JobDoc(id=job_id, scenario={"steps": [step_to_dict(s) for s in scenario.steps]})
+    doc.save()
+
+    return {"step": step_to_dict(step), "context": scenario.context}
+
+
+@app.post("/scenario/{job_id}/run_all")
+def run_all(job_id: str):
+    scenario = SCENARIOS.get(job_id)
+    if not scenario:
+        raise HTTPException(status_code=404, detail="Scenario not found")
+
+    scenario.run_all()
+
+    doc = JobDoc(id=job_id, scenario={"steps": [step_to_dict(s) for s in scenario.steps]})
+    doc.save()
+    return {"status": "done", "steps": [step_to_dict(s) for s in scenario.steps]}
+
+
+@app.get("/scenario/{job_id}")
+def get_status(job_id: str):
+    # читаем последнее сохранённое состояние
+    doc = JobDoc.load(job_id)
+    if not doc:
+        raise HTTPException(status_code=404, detail="Scenario not found")
+    return doc.scenario
+
+
+@app.get("/measurement/{meas_id}/decode")
+def decode_measurement_endpoint(
+    meas_id: int,
+    averaging_num: int = Query(default=1, ge=1),
+    data_num: int = Query(default=1, ge=1),
+    channel_num: int = Query(default=0, ge=0),
+    zero_fill: int = Query(default=0, ge=0),
+    voltage_range: float = Query(default=0.02, gt=0),
+):
+    """
+    Fetch raw measurement data from the spectrometer and return decoded
+    signal (volts) + amplitude spectrum.
+    """
+    from .clients.spec_cl import SpectrometerClient
+
+    spectrometer_url = os.getenv("SPECTROMETER_URL", "http://localhost:8000")
+    spec_user = os.getenv("SPECTROMETER_USER", "admin")
+    spec_pass = os.getenv("SPECTROMETER_PASSWORD", "admin")
+
+    try:
+        spec = SpectrometerClient(
+            base_url=spectrometer_url,
+            username=spec_user,
+            password=spec_pass,
+        )
+    except Exception as e:
+        raise HTTPException(status_code=503, detail=f"Cannot connect to spectrometer: {e}")
+
+    try:
+        raw = spec.get_data(meas_id)
+    except Exception as e:
+        raise HTTPException(status_code=502, detail=f"Failed to fetch data from spectrometer: {e}")
+
+    try:
+        result = decode_measurement(
+            raw_json=raw,
+            averaging_num=averaging_num,
+            data_num=data_num,
+            channel_num=channel_num,
+            zero_fill=zero_fill,
+            voltage_range=voltage_range,
+        )
+    except ValueError as e:
+        raise HTTPException(status_code=422, detail=str(e))
+
+    return result

+ 65 - 0
services/orchestrator/orchestrator/run_scenario.py

@@ -0,0 +1,65 @@
+"""
+Прогон базового сценария через оркестратор
+"""
+
+import time
+import requests
+import sys
+import json
+
+
+ORCH_URL = "http://localhost:1717"
+SCENARIO_ID = "full_pipeline"
+
+
+def load_scenario(scenario_id: str):
+    url = f"{ORCH_URL}/scenario/load/{scenario_id}"
+    r = requests.post(url, timeout=10)
+    r.raise_for_status()
+    return r.json()["job_id"]
+
+
+def run_all(job_id: str):
+    url = f"{ORCH_URL}/scenario/{job_id}/run_all"
+    r = requests.post(url, timeout=10)
+    r.raise_for_status()
+    return r.json()
+
+
+def get_status(job_id: str):
+    url = f"{ORCH_URL}/scenario/{job_id}"
+    r = requests.get(url, timeout=10)
+    r.raise_for_status()
+    return r.json()
+
+
+def main():
+    try:
+        # 1. Загружаем сценарий
+        job_id = load_scenario(SCENARIO_ID)
+        print(f"✅ Загружен сценарий '{SCENARIO_ID}', job_id={job_id}")
+
+        # 2. Запускаем все шаги
+        print("▶ Запускаю выполнение всех шагов...")
+        run_all(job_id)
+
+        # 3. Ждём завершения
+        for _ in range(60):  # 60 циклов по 2 сек = 2 минуты
+            status = get_status(job_id)
+            steps = status.get("steps", [])
+            done = all(step.get("status") in ("done", "failed") for step in steps)
+            print("⏳ Статус:", json.dumps(status, ensure_ascii=False, indent=2))
+            if done:
+                print("\n🏁 Сценарий завершён")
+                break
+            time.sleep(2)
+        else:
+            print("⚠️ Таймаут ожидания")
+
+    except Exception as e:
+        print("❌ Ошибка:", e)
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 64 - 0
services/orchestrator/orchestrator/scenario.py

@@ -0,0 +1,64 @@
+
+from typing import Callable, List, Dict, Any, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+import inspect
+import copy
+
+class StepStatus(str, Enum):
+    pending = "pending"
+    running = "running"
+    done = "done"
+    failed = "failed"
+
+@dataclass
+class Step:
+    name: str
+    func: Callable
+    params: Dict[str, Any] = field(default_factory=dict)  # параметры из шаблона
+    status: StepStatus = StepStatus.pending
+    result: dict = field(default_factory=dict)
+
+@dataclass
+class Scenario:
+    steps: List[Step]
+    current: int = 0
+    context: Dict[str, Any] = field(default_factory=dict)  # общий контекст (слияние результатов шагов)
+
+    def _resolve_params(self, step: Step) -> Dict[str, Any]:
+        """
+        Готовим аргументы для вызова функции шага:
+        - Берём копию глобального context (слияние результатов всех прошлых шагов)
+        - Поверх накладываем step.params из шаблона (они имеют приоритет)
+        - Фильтруем по сигнатуре вызываемой функции (никаких лишних kwargs)
+        """
+        base = copy.deepcopy(self.context)
+        base.update(step.params or {})
+        sig = inspect.signature(step.func)
+        allowed = {}
+        for k, v in base.items():
+            if k in sig.parameters or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
+                allowed[k] = v
+        return allowed
+
+    def run_next(self) -> Step:
+        if self.current >= len(self.steps):
+            raise IndexError("No more steps to run")
+        step = self.steps[self.current]
+        step.status = StepStatus.running
+        try:
+            kwargs = self._resolve_params(step)
+            step.result = step.func(**kwargs)
+            # обновляем общий контекст результатом шага (поверх)
+            if isinstance(step.result, dict):
+                self.context.update(step.result)
+            step.status = StepStatus.done
+        except Exception as e:
+            step.status = StepStatus.failed
+            step.result = {"error": str(e)}
+        self.current += 1
+        return step
+
+    def run_all(self):
+        while self.current < len(self.steps):
+            self.run_next()

+ 15 - 0
services/orchestrator/orchestrator/scenario_loader.py

@@ -0,0 +1,15 @@
+import yaml, json
+from pathlib import Path
+
+SCENARIO_DIR = Path("scenarios")
+SCENARIO_DIR.mkdir(exist_ok=True)
+
+def load_scenarios():
+    scenarios = {}
+    for file in SCENARIO_DIR.glob("*.yaml"):
+        data = yaml.safe_load(file.read_text(encoding="utf-8"))
+        scenarios[data["id"]] = data
+    for file in SCENARIO_DIR.glob("*.json"):
+        data = json.loads(file.read_text(encoding="utf-8"))
+        scenarios[data["id"]] = data
+    return scenarios

+ 101 - 0
services/orchestrator/orchestrator/signal_decoder.py

@@ -0,0 +1,101 @@
+import base64
+from typing import Any, Dict, List, Optional
+
+import numpy as np
+import scipy.fft as fft
+
+
+def _find_measurement(data: List[Dict], averaging_num: int, data_num: int) -> Optional[Dict]:
+    for m in data:
+        if m.get("averaging_num") == averaging_num and m.get("data_num") == data_num:
+            return m
+    return None
+
+
+def _get_channel_data(measurement: Dict, channel_num: int) -> Optional[str]:
+    for ch in measurement.get("channel_data", []):
+        if ch.get("channel_num") == channel_num:
+            return ch.get("channel_data")
+    return None
+
+
+def _channel_value(value: Any, channel_num: int):
+    if isinstance(value, list):
+        return value[channel_num]
+    return value
+
+
+def decode_measurement(
+    raw_json: Any,
+    averaging_num: int = 1,
+    data_num: int = 1,
+    channel_num: int = 0,
+    zero_fill: int = 0,
+    voltage_range: float = 0.02,
+) -> Dict[str, Any]:
+    """
+    Decodes raw measurement JSON from the spectrometer.
+
+    raw_json can be:
+      - list of measurement dicts (native format)
+      - single measurement dict
+      - dict with a "data" or "results" key wrapping a list
+    """
+    if isinstance(raw_json, list):
+        data = raw_json
+    elif isinstance(raw_json, dict):
+        # unwrap common envelope keys
+        inner = raw_json.get("data") or raw_json.get("results") or raw_json.get("measurements")
+        if isinstance(inner, list):
+            data = inner
+        else:
+            data = [raw_json]
+    else:
+        raise ValueError(f"Unexpected raw_json type: {type(raw_json)}")
+
+    meas = _find_measurement(data, averaging_num, data_num)
+    if meas is None:
+        raise ValueError(
+            f"Measurement not found (averaging_num={averaging_num}, data_num={data_num})"
+        )
+
+    raw_b64 = _get_channel_data(meas, channel_num)
+    if raw_b64 is None:
+        raise ValueError(f"Channel {channel_num} not found in measurement")
+
+    points_raw = meas.get("measurement_points")
+    rate_raw = meas.get("measurement_rate")
+
+    if rate_raw is None:
+        raise ValueError("measurement_rate is missing from measurement")
+
+    rate = float(_channel_value(rate_raw, channel_num))
+    points = int(_channel_value(points_raw, channel_num)) if points_raw is not None else None
+
+    decoded = base64.b64decode(raw_b64)
+    int_data = np.frombuffer(decoded, dtype="<i2")
+    if points is not None:
+        int_data = int_data[:points]
+
+    scaled = voltage_range * int_data.astype(float) / 32768.0
+
+    segment = scaled - np.mean(scaled)
+    n = len(segment)
+    if zero_fill > 0:
+        segment = np.append(segment, np.zeros(zero_fill))
+
+    transform = fft.rfft(segment) * 2 / n
+    freqs = fft.rfftfreq(len(segment), d=1.0 / rate)
+
+    return {
+        "measurement_id": meas.get("measurement_id"),
+        "points": n,
+        "rate_hz": rate,
+        "duration_s": n / rate,
+        "signal": scaled.tolist(),
+        "spectrum": {
+            "freqs": freqs.tolist(),
+            "amplitudes": np.abs(transform).tolist(),
+            "phases": np.angle(transform).tolist(),
+        },
+    }

+ 24 - 0
services/orchestrator/orchestrator/tasks_plug.py

@@ -0,0 +1,24 @@
+import time
+
+def start_measurement(**kwargs):
+    time.sleep(1)
+    return {"measurement_id": "meas_stub", "status": "ok"}
+
+def wait_data_ready(**kwargs):
+    time.sleep(1)
+    return {"data_ready": True}
+
+def fetch_data(format="h5", **kwargs):
+    time.sleep(1)
+    return {"raw_file": f"raw_stub.{format}"}
+
+def run_reconstruction(sequence_name="linear", digit="2d", phase_shift=False, **kwargs):
+    time.sleep(1)
+    return {"session_id": "sess_stub", "status": "done", "sequence": sequence_name}
+
+TASK_REGISTRY = {
+    "start_measurement": start_measurement,
+    "wait_data_ready": wait_data_ready,
+    "fetch_data": fetch_data,
+    "run_reconstruction": run_reconstruction,
+}

+ 63 - 0
services/orchestrator/orchestrator/tasks_real.py

@@ -0,0 +1,63 @@
+import os
+
+from .clients.spec_cl import SpectrometerClient
+from .clients.reco_cl import ReconstructorClient
+from datetime import datetime, timezone
+
+def _iso_now():
+    return datetime.now(timezone.utc).isoformat()
+
+_SPECTROMETER_URL = os.getenv("SPECTROMETER_URL", "http://localhost:8000")
+_RECONSTRUCTOR_URL = os.getenv("RECONSTRUCTOR_URL", "http://localhost:8081")
+
+spec = SpectrometerClient(base_url=_SPECTROMETER_URL, username="admin", password="admin")
+reco = ReconstructorClient(base_url=_RECONSTRUCTOR_URL)
+
+# Шаг 1: старт измерения
+def start_measurement(info=None, **kwargs):
+    # Унифицируем вход: если пришли поля верхнего уровня, упакуем в {"info": {...}}
+    payload = {}
+    if info is not None:
+        payload["info"] = dict(info)
+    else:
+        payload["info"] = dict(kwargs)  # поддержка старых шаблонов с плоскими полями
+    payload["info"].setdefault("time", _iso_now())
+    meas_id = spec.start_measurement(payload)  # вернёт числовой id
+    return {"measurement_id": meas_id}
+
+# Шаг 2: ждём готовность данных
+def wait_data_ready(measurement_id=None, **kwargs):
+    if measurement_id is None:
+        raise ValueError("wait_data_ready: measurement_id is required (не пришёл из предыдущего шага)")
+    state = spec.wait_data_ready(measurement_id)
+    return {"state": state}
+
+# Шаг 3: забираем данные
+def fetch_data(measurement_id=None, **kwargs):
+    if measurement_id is None:
+        raise ValueError("fetch_data: measurement_id is required (не пришёл из предыдущего шага)")
+    data = spec.get_data(measurement_id)
+    # Попробуем угадать полезное поле для следующего шага (URL или id файла)
+    raw_url = data.get("raw_h5") or data.get("file_url") or data.get("raw_file") or None
+    out = {"data": data}
+    if raw_url:
+        out["raw_file"] = raw_url
+    return out
+
+# Шаг 4: реконструкция
+def run_reconstruction(raw_file=None, **kwargs):
+    # В минимальном случае ждём, что предыдущий шаг положил raw_file (URL/идентификатор)
+    body = dict(kwargs)
+    if raw_file is not None:
+        body.setdefault("file_raw_id", raw_file)
+    resp = reco.reconstruct(body, raw_format="h5")
+    session_id = resp.get("session_id")
+    session = reco.get_session(session_id) if session_id else {}
+    return {"session_id": session_id, "status": session.get("status", "unknown")}
+
+TASK_REGISTRY = {
+    "start_measurement": start_measurement,
+    "wait_data_ready": wait_data_ready,
+    "fetch_data": fetch_data,
+    "run_reconstruction": run_reconstruction,
+}

+ 6 - 0
services/orchestrator/requirements.txt

@@ -0,0 +1,6 @@
+fastapi==0.115.0
+uvicorn[standard]==0.30.6
+pyyaml==6.0.2
+requests==2.32.3
+numpy>=1.26
+scipy>=1.13

+ 22 - 0
services/orchestrator/scenarios/full_pipeline.yaml

@@ -0,0 +1,22 @@
+id: full_pipeline
+steps:
+  - name: start_measurement
+    params:
+      info:
+        infostr: "template_signal"
+        engine: "DefaultEngine"
+        time: "2025-08-29T12:34:56.123456Z"
+        iadc: { device_model: "PS4000A", srate: 80000000, points: [1000], n_channels: 2, channel_ranges: [8,8],
+                n_triggers: 1, averaging: 1, trigger_channel: 0, trig_direction: 0, threshold: 5000, auto_measure_time: 10000, enabled: true }
+        isync: { device_model: "DuePP", file: "test.xml", port: 7 }
+        isdr:  { device_model: "HackRF", srate: 2000000, freq: 3000000, ampl: true, gain: 35, file: "test.bin" }
+        igrax: { device_model: "GRU", ip: "127.0.0.1", file: "test.txt", enabled: false }
+        igray: { device_model: "GRU", ip: "127.0.0.1", file: "test.txt", enabled: false }
+        igraz: { device_model: "GRU", ip: "127.0.0.1", file: "test.txt", enabled: false }
+  - name: wait_data_ready
+  - name: fetch_data
+  - name: run_reconstruction
+    params:
+      sequence_name: "linear"
+      digit: "2d"
+      phase_shift: false

+ 4 - 0
services/orchestrator/scenarios/only_reconstruction.yaml

@@ -0,0 +1,4 @@
+id: only_reconstruction
+steps:
+  - name: run_reconstruction
+    params: { sequence_name: linear_epi, digit: 3d }

+ 20 - 0
services/reconstructor/Dockerfile

@@ -0,0 +1,20 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1 \
+    PYTHONPATH=/app \
+    MPLBACKEND=Agg
+
+RUN apt-get update && apt-get install -y --no-install-recommends curl libgomp1 \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+EXPOSE 8000
+
+CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]

+ 74 - 0
services/reconstructor/EPI_SE_240824_1418.json

@@ -0,0 +1,74 @@
+{
+    "sl_nb": 2,
+    "sl_thkn": 0.001,
+    "sl_gap": 100.0,
+    "FoV_f": 0.3,
+    "FoV_p_image": 0.3,
+    "Nf": 128.0,
+    "Np_image": 128.0,
+    "BW_pixel": 500.0,
+    "TE": 0.4,
+    "TR": 1.0,
+    "FA": 90.0,
+    "RA": 180.0,
+    "average": 1,
+    "ph_over_phase": 0.0,
+    "part_fourier_factor_phase": 1.0,
+    "TI": 0.1,
+    "TI_extra": 0.1,
+    "TEeff": 0.2,
+    "ZF": false,
+    "RESTORE": false,
+    "FS": false,
+    "SPAIR": false,
+    "WS": false,
+    "WE": false,
+    "IR": false,
+    "DIR": false,
+    "TIR": false,
+    "DB": false,
+    "SR": false,
+    "t2prepIR_sel": false,
+    "t2prepIR_nonsel": false,
+    "b": 0.0,
+    "flow_comp_fr": false,
+    "flow_comp_sl": false,
+    "recomb": false,
+    "SWI": false,
+    "interpolation": false,
+    "diffusion": false,
+    "save_ph_image": false,
+    "HASTE": false,
+    "rewind_gradient": false,
+    "SENSE": false,
+    "GRAPPA": false,
+    "prescan": false,
+    "PI_factor": 1,
+    "FoV_p": 0.3,
+    "Np": 128,
+    "dG": 0.00032,
+    "magn_prep": "off",
+    "gamma": 42576000.0,
+    "G_amp_max": 1609372.8,
+    "G_slew_max": 5151696000.0,
+    "rf_raster_time": 1e-06,
+    "grad_raster_time": 1e-05,
+    "tau_max": 0.00032,
+    "B0": 1.5,
+    "rf_ringdown_time": 2e-05,
+    "rf_dead_time": 0.0001,
+    "adc_dead_time": 1e-05,
+    "t_BW_product_ex1": 3.8,
+    "t_BW_product_ref1": 4.2,
+    "t_BW_product_ex2": 3.55,
+    "t_BW_product_ref2": 3.55,
+    "t_ex1": 0.00205,
+    "t_ref1": 0.00256,
+    "t_ex2": 0.0031,
+    "t_ref2": 0.00388,
+    "t_BW_product_ex": 3.55,
+    "t_BW_product_ref": 3.55,
+    "t_ex": 0.0031,
+    "t_ref": 0.00388,
+    "apodization": 0.27
+}

+ 76 - 0
services/reconstructor/MESE_080825_0144.json

@@ -0,0 +1,76 @@
+{
+    "sl_nb": 1,
+    "sl_thkn": 0.005,
+    "sl_gap": 100.0,
+    "FoV_f": 0.25,
+    "FoV_p_image": 0.25,
+    "Nf": 32.0,
+    "Np_image": 32.0,
+    "BW_pixel": 500.0,
+    "TE": 0.01,
+    "IE": 0.01,
+    "contrasts": 5,
+    "concats": 1,
+    "TR": 1.0,
+    "FA": 90.0,
+    "RA": 180.0,
+    "average": 1,
+    "D_scans": 1,
+    "ph_over_phase": 0.0,
+    "part_fourier_factor_phase": 1.0,
+    "TI": 0.1,
+    "TEeff": 0.2,
+    "ZF": false,
+    "FS": false,
+    "SPAIR": false,
+    "WS": false,
+    "WE": false,
+    "IR": false,
+    "DIR": false,
+    "TIR": false,
+    "DB": false,
+    "SR": false,
+    "t2prepIR_sel": false,
+    "t2prepIR_nonsel": false,
+    "save_ph_image": false,
+    "SENSE": false,
+    "GRAPPA": false,
+    "prescan": false,
+    "PI_factor": 1,
+    "PI_factor_ss": 1,
+    "Np_image_input": 32.0,
+    "saturators": false,
+    "sat_oris": [],
+    "sat_offcenters": [],
+    "sat_thicknesses": [],
+    "trigger": "off",
+    "Dixon": false,
+    "Np": 32,
+    "FoV_p": 0.25,
+    "dG": 0.00032,
+    "magn_prep": "off",
+    "gamma": 42576000.0,
+    "G_amp_max": 1609372.8,
+    "G_slew_max": 5151696000.0,
+    "rf_raster_time": 1e-06,
+    "grad_raster_time": 1e-05,
+    "tau_max": 0.00032,
+    "B0": 1.5,
+    "fat_ppm": -3.5,
+    "rf_ringdown_time": 2e-05,
+    "rf_dead_time": 0.0001,
+    "adc_dead_time": 1e-05,
+    "t_BW_product_ex1": 3.8,
+    "t_BW_product_ref1": 4.2,
+    "t_BW_product_ex2": 3.55,
+    "t_BW_product_ref2": 3.55,
+    "t_ex1": 0.00205,
+    "t_ref1": 0.00256,
+    "t_ex2": 0.0031,
+    "t_ref2": 0.00388,
+    "t_BW_product_ex": 3.8,
+    "t_BW_product_ref": 4.2,
+    "t_ex": 0.00205,
+    "t_ref": 0.00256,
+    "apodization": 0.27
+}

+ 0 - 0
services/reconstructor/api/__init__.py


+ 11 - 0
services/reconstructor/api/core.py

@@ -0,0 +1,11 @@
+from pathlib import Path
+import os
+from concurrent.futures import ThreadPoolExecutor
+import matplotlib
+matplotlib.use("Agg")  
+
+BASE_DIR = Path(__file__).resolve().parents[1]
+STORE_DIR = BASE_DIR / "store"
+STORE_DIR.mkdir(parents=True, exist_ok=True)
+
+EXECUTOR = ThreadPoolExecutor(max_workers=os.cpu_count() or 4)

+ 15 - 0
services/reconstructor/api/main.py

@@ -0,0 +1,15 @@
+from fastapi import FastAPI
+from .routers.health import router as health_router
+from .routers.files import router as files_router
+from .routers.reconstruct import router as reconstruct_router
+
+app = FastAPI(title="Reconstruction API", version="1.0.0")
+
+app.include_router(health_router)
+app.include_router(files_router)
+app.include_router(reconstruct_router)
+
+if __name__ == "__main__":
+    import uvicorn
+    # 127.0.0.1:8000/docs
+    uvicorn.run(app, host="0.0.0.0", port=8000)

+ 380 - 0
services/reconstructor/api/recon_app.py

@@ -0,0 +1,380 @@
+import json
+
+import h5py
+import numpy as np
+from prompt_toolkit.key_binding.bindings.named_commands import self_insert
+from reco import *
+from seqData import SequenceDataFrame
+from service import ReconstructionService
+from traj import *
+
+
+class ReconstructionApp():
+
+    def __init__(self, name, digit, shift):
+        super().__init__()
+        self.name = name  # type seq (linear|non|epi|radial)
+        self.digit = digit  # '2d' | '3d'
+        self.phase_shift = shift  # bool (сдвиг надо/ не надо)
+
+    def start_reconstruction(self, path_raw_data, path_np_data_json, path_order_json):
+        try:
+            service = ReconstructionService()
+
+            nX, nY, nZ, d_scans, nContr, spoil, ETL, N_TE, phi_wing, N_wings = self.read_consts(path_np_data_json)
+            service.path_raw_data = path_raw_data
+
+            raw_data = service.read_mat_file()
+            print(raw_data.shape)
+            sequence_data = SequenceDataFrame()
+            sequence_data.read_raw_data(raw_data)
+            sequence_data.read_dots(int(nX))
+            sequence_name = self.name
+
+            if sequence_name == 'nonlinear_decart(tse)' or sequence_name == 'radial_propeller':
+                k_space_order = self.read_order_from_json(path_order_json)
+                self.k_space_order = k_space_order
+
+            arr_mat = np.array(sequence_data.mat_df['dot'])
+            digit = self.digit
+
+            if not path_raw_data or not path_np_data_json:
+                raise ValueError("Пожалуйста, выберите файлы и введите данные перед началом реконструкции.")
+            else:
+                json_data = path_np_data_json
+                if digit == '2d':
+                    self.reconstruct_2d(sequence_name, arr_mat, json_data)
+                elif digit == '3d':
+                    self.reconstruct_3d(sequence_name, arr_mat, json_data)
+
+        except ValueError as ve:
+            raise ValueError(str(ve))
+        except NotImplementedError as nie:
+            raise NotImplementedError(str(nie))
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")
+
+    def read_order_from_json(self, path_order_json):
+        with open(path_order_json, 'r') as f:
+            data = json.load(f)
+        return data.get("k_space_order", [])
+
+    def read_mat_file(self, path):
+        with h5py.File(path, 'r') as f:
+            key = list(f.keys())[0]
+        with h5py.File(path, 'r') as f:
+            data = f[key][:]
+        return np.array(data)
+
+    def get_value_or_default(self, json_obj, key, default_value=1):
+        return int(json_obj.get(key, default_value))
+
+    def read_consts(self, path):
+        with open(path, 'r') as file:
+            data = json.load(file)
+        d_scans = self.get_value_or_default(data, "D_scans", 1)
+        Np = int(data['Np'])
+        Nf = int(data['Nf'])
+        sl_nb = int(data['sl_nb'])
+        nContrast = self.get_value_or_default(data, "contrasts", 1)
+        spoil = self.get_value_or_default(data, "RF_spoil", 0)
+        ETL = self.get_value_or_default(data, 'ETL', 0)
+        N_TE = self.get_value_or_default(data, 'N_TE', 0)
+        phi_wing = self.get_value_or_default(data, "phi_wing", 0)
+        N_wings = self.get_value_or_default(data, "N_wings", 0)
+        return Np, Nf, sl_nb, d_scans, nContrast, spoil, ETL, N_TE, phi_wing, N_wings
+
+    def read_consts_3d(self, path):
+        with open(path, 'r') as file:
+            data = json.load(file)
+        d_scans = self.get_value_or_default(data, "D_scans", 1)
+        Np = int(data['Np'])
+        Nf = int(data['Nf'])
+        sl_nb = int(data['Nss_image'])  # слои в 3д
+        if sl_nb % 2 == 1: sl_nb -= 1
+        nContrast = self.get_value_or_default(data, "contrasts", 1)
+        spoil = self.get_value_or_default(data, "RF_spoil", 0)
+        return Np, Nf, sl_nb, d_scans, nContrast, spoil
+
+    def reconstruct_2d(self, sequence_name, mat_data, json_data):
+        try:
+            nX, nY, nZ, d_scans, nContr, spoil, ETL, N_TE, phi_wing, N_wings = self.read_consts(json_data)
+            if self.phase_shift == False:
+                spoil = 0
+            if sequence_name == "linear_decart":
+                all_data = gather_data_along_trajectory(mat_data, nX, nY, nZ, nContr)
+                save_ffft(all_data, spoil)
+            elif sequence_name == "nonlinear_decart(tse)":
+                all_data = gather_data_along_trajectory_nonlinear(mat_data, self.k_space_order, nX, nY, nZ, nContr)
+                save_ffft(all_data, spoil)
+            elif sequence_name == "linear_epi":
+                all_data = gather_data_along_trajectory_linear_epi(mat_data, nX, nY, nZ, nContr)
+                save_ffft(all_data, spoil)
+            elif sequence_name == "radial_propeller":
+                all_data = gather_data_along_trajectory_radial(mat_data, self.k_space_order, nX, nY, nZ, phi_wing,
+                                                               N_wings)
+                save_ffft(all_data, spoil)
+            else:
+                raise NotImplementedError("Для данного типа ИП еще не добавлена функция.")
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")
+
+    def reconstruct_3d(self, sequence_name, mat_data, json_data):
+        try:
+            nX, nY, nZ, d_scans, nContr, spoil = self.read_consts_3d(json_data)
+            if self.phase_shift == False:
+                spoil = 0
+            if sequence_name == "linear_decart":
+                all_data = gather_data_along_trajectory(mat_data, nX, nY, nZ, nContr)
+                save_ffft_3d(all_data, spoil)
+            elif sequence_name == "nonlinear_decart(tse)":
+                all_data = gather_data_along_trajectory_nonlinear(mat_data, self.k_space_order, nX, nY, nZ, nContr)
+                save_ffft_3d(all_data, spoil)
+            else:
+                raise NotImplementedError("Для данного типа ИП еще не добавлена функция.")
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")
+
+
+class ReconstructionH5():
+    def __init__(self, name, digit, shift):
+        super().__init__()
+        self.name = name  # type seq (linear|non|epi|radial)
+        self.digit = digit  # '2d' | '3d'
+        self.phase_shift = shift  # bool (сдвиг надо/ не надо)
+
+    def start_reconstruction(self, path_raw_data, path_np_data_json, path_order_json):
+        try:
+            service = ReconstructionService()
+
+            nX, nY, nZ, d_scans, nContr, spoil, ETL, N_TE, phi_wing, N_wings = self.read_consts(path_np_data_json)
+            service.path_raw_data = path_raw_data
+
+            raw_data = self.read_hdf5_file(path_raw_data)  # [coil, y*z*contrast, x]
+            print(raw_data.shape)
+
+            sequence_name = self.name
+
+            if sequence_name == 'nonlinear_decart(tse)' or sequence_name == 'radial_propeller':
+                k_space_order = self.read_order_from_json(path_order_json)
+                self.k_space_order = k_space_order
+
+            digit = self.digit
+
+            if not path_raw_data or not path_np_data_json:
+                raise ValueError("Пожалуйста, выберите файлы и введите данные перед началом реконструкции.")
+            else:
+                json_data = path_np_data_json
+                if digit == '2d':
+                    self.reconstruct_2d_h5(sequence_name, raw_data, json_data)
+                elif digit == '3d':
+                    self.reconstruct_3d_h5(sequence_name, raw_data, json_data)
+
+        except ValueError as ve:
+            raise ValueError(str(ve))
+        except NotImplementedError as nie:
+            raise NotImplementedError(str(nie))
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")
+
+    def read_order_from_json(self, path_order_json):
+        with open(path_order_json, 'r') as f:
+            data = json.load(f)
+        return data.get("k_space_order", [])
+
+    def read_hdf5_file(self, path):
+        with h5py.File(path, 'r') as f:
+            key = list(f.keys())[0]
+            data = f[key]
+            keyw = list(data.keys())[0]
+            arr_data = np.array(data[keyw])
+        return np.array(arr_data)  # [coil, y*z*contrast, x]
+
+    def get_value_or_default(self, json_obj, key, default_value=1):
+        return int(json_obj.get(key, default_value))
+
+    def read_consts(self, path):
+        with open(path, 'r') as file:
+            data = json.load(file)
+        d_scans = self.get_value_or_default(data, "D_scans", 1)
+        Np = int(data['Np'])
+        Nf = int(data['Nf'])
+        sl_nb = int(data['sl_nb'])
+        nContrast = self.get_value_or_default(data, "contrasts", 1)
+        spoil = self.get_value_or_default(data, "RF_spoil", 0)
+        ETL = self.get_value_or_default(data, 'ETL', 0)
+        N_TE = self.get_value_or_default(data, 'N_TE', 0)
+        phi_wing = self.get_value_or_default(data, "phi_wing", 0)
+        N_wings = self.get_value_or_default(data, "N_wings", 0)
+        return Np, Nf, sl_nb, d_scans, nContrast, spoil, ETL, N_TE, phi_wing, N_wings
+
+    def read_consts_3d(self, path):
+        with open(path, 'r') as file:
+            data = json.load(file)
+        d_scans = self.get_value_or_default(data, "D_scans", 1)
+        Np = int(data['Np'])
+        Nf = int(data['Nf'])
+        sl_nb = int(data['Nss_image'])  # слои в 3д
+        if sl_nb % 2 == 1: sl_nb -= 1
+        nContrast = self.get_value_or_default(data, "contrasts", 1)
+        spoil = self.get_value_or_default(data, "RF_spoil", 0)
+        return Np, Nf, sl_nb, d_scans, nContrast, spoil
+
+    def reconstruct_2d_h5(self, sequence_name, mat_data, json_data):
+        # mat_data = [coil, y*contrast*z, x] -> [x, y, z, contrast, coil]
+        try:
+            nX, nY, nZ, d_scans, nContr, spoil, ETL, N_TE, phi_wing, N_wings = self.read_consts(json_data)
+            if self.phase_shift == False:
+                spoil = 0
+            if sequence_name == "linear_decart":
+                all_data = self.gather_data_along_trajectory_h5(mat_data, nX, nY, nZ, nContr)
+                save_ffft(all_data, spoil)
+            elif sequence_name == "nonlinear_decart(tse)":
+                all_data = self.gather_data_along_trajectory_nonlinear_h5(mat_data, self.k_space_order, 
+                                                                          nX, nY, nZ, nContr)
+                save_ffft(all_data, spoil)
+            elif sequence_name == "linear_epi":
+                all_data = self.gather_data_along_trajectory_linear_epi_h5(mat_data, nX, nY, nZ, nContr)
+                save_ffft(all_data, spoil)
+            elif sequence_name == "radial_propeller":
+                all_data = self.gather_data_along_trajectory_radial_h5(mat_data, 
+                                                                       self.k_space_order, nX, nY, nZ, 
+                                                                       phi_wing, N_wings)
+                save_ffft(all_data, spoil)
+            else:
+                raise NotImplementedError("Для данного типа ИП еще не добавлена функция.")
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")
+
+    def gather_data_along_trajectory_h5(self, mat_data, nX, nY, nZ, nContr, nCoils=1):
+        nCoils = mat_data.shape[2]
+        gathered_data = np.zeros((nX, nY, nZ, nContr, nCoils), dtype=np.complex128)
+
+        i = 0
+
+        for y in range(0, nY):
+            for z in range(0, nZ):
+                for contrast in range(0, nContr):
+                    for x in range(0, nX):
+                        gathered_data[x, y, z, contrast, :] = mat_data[x, i, :]
+                    i += 1
+
+        return gathered_data
+
+    def gather_data_along_trajectory_nonlinear_h5(self, mat_data, order, nX, nY, nZ, nContr, nCoils=1):
+        # out: [x, y, z, contrast, coil]
+        nCoils = mat_data.shape[2]
+
+        gathered_data = np.zeros((nX, nY, nZ, nContr, nCoils), dtype=np.complex128)
+
+        N_center = (nY - 1) // 2
+
+        if nY % 2 == 0:
+            N_center += 1
+
+        # print('N_center', N_center)
+        step_y = order
+
+        # print("STEP TSE", step_y)
+
+        i = 0
+        print(len(step_y))
+        index_y = len(step_y)
+        # for coil in range(nCoils):
+        for z in range(0, nZ):
+            for ind in range(index_y):
+                # for z in range(0, nZ):
+                for y in step_y[ind]:
+                    # print(y, "step", step_y[ind])
+                    if not isinstance(y, str):
+                        # for z in range(0, nZ):
+                        for x in range(0, nX):
+                            gathered_data[x, y, z, 0, :] = mat_data[x, i, :]
+                        i += 1
+
+      
+        return gathered_data
+
+    def gather_data_along_trajectory_linear_epi_h5(self, mat_data, nX, nY, nZ, nContr, nCoils=1):
+    # out: [x, y, z, contrast, coil]
+        
+        nCoils = mat_data.shape[2]
+        gathered_data = np.zeros((nX, nY, nZ, nContr, nCoils), dtype=np.complex128)
+    
+        i = 0
+        # for coil in range(nCoils):
+        for z in range(0, nZ):
+            for y in range(0, nY):
+                for contrast in range(0, nContr):
+                    if y % 2 == 1:
+                        for x in range(0, nX):
+                            gathered_data[y, x, z, contrast, :] = mat_data[x, i, :]
+                        i += 1
+                    else:
+                        for x in range(nX - 1, -1, -1):
+                            gathered_data[y, x, z, contrast, :] = mat_data[x, i, :]
+                        i += 1
+    
+        return gathered_data
+
+    def gather_data_along_trajectory_radial_h5(self, mat_data, order, nX, nY, nZ, phi_wing, N_wings, nCoils=1):
+        # out: [x, y, z, contrast, coil]
+      
+        nCoils = mat_data.shape[2]
+        k_space_not_rotate = self.read_raw_data_radial_h5(mat_data, order, nX, N_wings, nCoils)
+     
+        k_space = rotate_radial(k_space_not_rotate, phi_wing, N_wings)   # in traj
+        k_spaces_with_coils = np.stack(k_space, axis=0)
+        k_spaces_with_contrast = np.expand_dims(k_spaces_with_coils, axis=1)
+        k_spaces = np.transpose(k_spaces_with_contrast, (3, 4, 2, 1, 0))
+        print(k_spaces.shape)
+
+        return k_spaces
+
+    def read_raw_data_radial_h5(self, mat_data, order, nX, N_wings, nCoils):
+        # out: [coil, slice, n_wing, x, y]
+        # in: [coil, z*n_wings*y, x]
+        
+        nSlices = int((mat_data.shape[1]) / N_wings / len(order[0]))
+        k_space_size = nX
+    
+        k_space = np.zeros((nCoils, nSlices, N_wings, k_space_size, k_space_size), dtype=np.complex128)
+    
+        center_k_space = k_space_size // 2  # положение по Х, центр меняться не будет, можно учитывать как Мх
+        step_lines = order
+    
+        # k_spaces_not_rotate = []
+    
+        ind = 0
+      
+        for blade_number in range(0, N_wings, 1):
+            for iSlice in range(nSlices):
+    
+                for arr_line_number in step_lines:
+                        # print(arr_line_number, "-arr_line_number")
+                    for line_number in arr_line_number:
+                            # print(line_number, "-line_number")
+                        if not isinstance(line_number, str):
+                            for index in range(nX):
+                                    # print(index, "-index")
+                                radius = center_k_space + line_number - (len(arr_line_number) // 2)
+                                k_space[:, iSlice, blade_number, radius, index] = mat_data[index,ind,:]
+                            ind += 1
+        
+        return k_space
+    
+    def reconstruct_3d_h5(self, sequence_name, mat_data, json_data):
+        try:
+            nX, nY, nZ, d_scans, nContr, spoil = self.read_consts_3d(json_data)
+            if self.phase_shift == False:
+                spoil = 0
+            if sequence_name == "linear_decart":
+                all_data = self.gather_data_along_trajectory_h5(mat_data, nX, nY, nZ, nContr)
+                save_ffft_3d(all_data, spoil)
+            elif sequence_name == "nonlinear_decart(tse)":
+                all_data = self.gather_data_along_trajectory_nonlinear_h5(mat_data, self.k_space_order, nX, nY, nZ, nContr)
+                save_ffft_3d(all_data, spoil)
+            else:
+                raise NotImplementedError("Для данного типа ИП еще не добавлена функция.")
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")

+ 0 - 0
services/reconstructor/api/routers/__init__.py


+ 8 - 0
services/reconstructor/api/routers/files.py

@@ -0,0 +1,8 @@
+from fastapi import APIRouter, UploadFile, File
+from ..storage import save_upload
+
+router = APIRouter()
+
+@router.post("/upload")
+def upload(file: UploadFile = File(...)):
+    return save_upload(file)

+ 8 - 0
services/reconstructor/api/routers/health.py

@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+from datetime import datetime
+
+router = APIRouter()
+
+@router.get("/health")
+def health():
+    return {"status": "ok", "time_utc": datetime.utcnow().isoformat()}

+ 138 - 0
services/reconstructor/api/routers/reconstruct.py

@@ -0,0 +1,138 @@
+import io, os, json, zipfile, traceback
+from fastapi import APIRouter, BackgroundTasks, HTTPException
+from fastapi.responses import FileResponse, StreamingResponse
+import matplotlib.pyplot as plt
+
+from ..core import EXECUTOR
+from ..schemas import StartSessionRequest, SessionStatus
+from ..session import Session, SESSIONS
+from ..storage import FILES
+from ..utils import SavefigRedirect, collect_outputs
+from ..recon_app import ReconstructionApp, ReconstructionH5
+
+router = APIRouter()
+
+@router.post("/reconstruct", response_model=SessionStatus)
+def reconstruct(req: StartSessionRequest, background: BackgroundTasks):
+    if req.file_raw_id not in FILES:  raise HTTPException(400, "file_raw_id не найден")
+    if req.file_json_id not in FILES: raise HTTPException(400, "file_json_id не найден")
+    if req.file_order_id and req.file_order_id not in FILES: raise HTTPException(400, "file_order_id не найден")
+
+    session = Session(
+        FILES[req.file_raw_id],
+        FILES[req.file_json_id],
+        FILES.get(req.file_order_id) if req.file_order_id else None,
+        req.sequence_name, req.digit, req.phase_shift
+    )
+    SESSIONS[session.session_id] = session
+
+    def _run():
+        savefig_orig = plt.savefig
+        try:
+            session.set(status="running", progress=0.1, message="init")
+
+            with SavefigRedirect(session.work_dir):
+                app_reco = ReconstructionApp(name=session.sequence_name, digit=session.digit, shift=session.phase_shift)
+                session.set(progress=0.2, message="read + prepare")
+                app_reco.start_reconstruction(
+                    path_raw_data=session.file_raw,
+                    path_np_data_json=session.file_json,
+                    path_order_json=session.file_order if session.file_order else session.file_json
+                )
+                session.set(progress=0.95, message="collect results")
+                session.result_files = collect_outputs(session.work_dir)
+                session.set(status="done", progress=1.0, message="done")
+        except Exception:
+            session.error_traceback = traceback.format_exc()
+            session.set(status="error", message="error")
+        finally:
+            plt.savefig = savefig_orig
+            with open(os.path.join(session.work_dir, "status.json"), "w", encoding="utf-8") as f:
+                json.dump(session.to_status().model_dump(), f, ensure_ascii=False, indent=2)
+
+    background.add_task(EXECUTOR.submit, _run)
+    return session.to_status()
+
+@router.post("/h5/reconstruct", response_model=SessionStatus)
+def reconstructFromH5(req: StartSessionRequest, background: BackgroundTasks):
+    if req.file_raw_id not in FILES:  raise HTTPException(400, "file_raw_id не найден")
+    if req.file_json_id not in FILES: raise HTTPException(400, "file_json_id не найден")
+    if req.file_order_id and req.file_order_id not in FILES: raise HTTPException(400, "file_order_id не найден")
+    
+    session = Session(
+        FILES[req.file_raw_id],
+        FILES[req.file_json_id],
+        FILES.get(req.file_order_id) if req.file_order_id else None,
+        req.sequence_name, req.digit, req.phase_shift
+    )
+    SESSIONS[session.session_id] = session
+    
+    def _run():
+        savefig_orig = plt.savefig
+        try:
+            session.set(status="running", progress=0.1, message="init")
+    
+            with SavefigRedirect(session.work_dir):
+                app_reco = ReconstructionH5(name=session.sequence_name, digit=session.digit, shift=session.phase_shift)
+                session.set(progress=0.2, message="read + prepare")
+                app_reco.start_reconstruction(
+                    path_raw_data=session.file_raw,
+                    path_np_data_json=session.file_json,
+                    path_order_json=session.file_order if session.file_order else session.file_json
+                )
+                session.set(progress=0.95, message="collect results")
+                session.result_files = collect_outputs(session.work_dir)
+                session.set(status="done", progress=1.0, message="done")
+        except Exception:
+            session.error_traceback = traceback.format_exc()
+            session.set(status="error", message="error")
+        finally:
+            plt.savefig = savefig_orig
+            with open(os.path.join(session.work_dir, "status.json"), "w", encoding="utf-8") as f:
+                json.dump(session.to_status().model_dump(), f, ensure_ascii=False, indent=2)
+    
+    background.add_task(EXECUTOR.submit, _run)
+    return session.to_status()    
+
+@router.get("/sessions/{session_id}", response_model=SessionStatus)
+def get_status(session_id: str):
+    session = SESSIONS.get(session_id)
+    if not session: raise HTTPException(404, "не найден")
+    return session.to_status()
+
+@router.get("/sessions/{session_id}/files")
+def list_files(session_id: str):
+    session = SESSIONS.get(session_id)
+    if not session: raise HTTPException(404, "не найден")
+    files = [os.path.basename(p) for p in session.result_files if os.path.isfile(p)]
+    return {"files": files}
+
+@router.get("/sessions/{session_id}/files/{name}")
+def download_file(session_id: str, name: str):
+    session = SESSIONS.get(session_id)
+    if not session: raise HTTPException(404, "не найден")
+    target = os.path.join(session.work_dir, name)
+    if not os.path.isfile(target): raise HTTPException(404, "файл не найден")
+    return FileResponse(target, filename=name)
+
+@router.get("/sessions/{session_id}/archive.zip")
+def download_zip(session_id: str):
+    session = SESSIONS.get(session_id)
+    if not session: raise HTTPException(404, "не найден")
+    mem = io.BytesIO()
+    with zipfile.ZipFile(mem, "w", zipfile.ZIP_DEFLATED) as zf:
+        for p in session.result_files:
+            if os.path.isfile(p):
+                zf.write(p, arcname=os.path.basename(p))
+        zf.writestr("status.json", json.dumps(session.to_status().model_dump(), ensure_ascii=False, indent=2))
+    mem.seek(0)
+    return StreamingResponse(mem, media_type="application/zip",
+                             headers={"Content-Disposition": f'attachment; filename=\"%s.zip\"' % session.session_id})
+
+@router.delete("/sessions/{session_id}")
+def delete_session(session_id: str):
+    from shutil import rmtree
+    session = SESSIONS.pop(session_id, None)
+    if not session: raise HTTPException(404, "не найден")
+    rmtree(session.work_dir, ignore_errors=True)
+    return {"deleted": session_id}

+ 30 - 0
services/reconstructor/api/schemas.py

@@ -0,0 +1,30 @@
+from datetime import datetime
+from typing import Optional, Dict, List
+
+import matplotlib
+
+matplotlib.use("Agg")
+
+from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks
+from fastapi.responses import FileResponse, StreamingResponse
+from pydantic import BaseModel, Field
+
+class StartSessionRequest(BaseModel):
+    file_raw_id: str = Field(..., description="id загруженного .mat/.h5")
+    file_json_id: str = Field(..., description="id загруженного JSON с параметрами")
+    file_order_id: Optional[str] = Field(None, description="id JSON с порядком k-space (для nonlinear/radial)")
+    sequence_name: str = Field(..., description="linear_decart | nonlinear_decart(tse) | linear_epi | radial_propeller")
+    digit: str = Field(..., description="'2d' или '3d'")
+    phase_shift: bool = Field(False, description="включать RF-spoil из JSON или обнулить")
+
+
+class SessionStatus(BaseModel):
+    session_id: str
+    status: str  # queued | running | done | error
+    progress: float
+    message: str
+    created_at: str
+    updated_at: str
+    result_files: List[str] = []
+    error_traceback: Optional[str] = None
+

+ 51 - 0
services/reconstructor/api/session.py

@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+from typing import Optional, Dict, List
+import os
+import uuid
+from datetime import datetime
+import matplotlib
+
+from .core import STORE_DIR
+from .schemas import SessionStatus
+
+matplotlib.use("Agg")
+
+
+class Session:
+    def __init__(self, file_raw: str, file_json: str, file_order: Optional[str], sequence_name: str, digit: str,
+                 phase_shift: bool):
+        self.session_id = str(uuid.uuid4())
+        self.file_raw = file_raw
+        self.file_json = file_json
+        self.file_order = file_order
+        self.sequence_name = sequence_name
+        self.digit = digit
+        self.phase_shift = phase_shift
+        self.status = "queued"
+        self.progress = 0.0
+        self.message = "queued"
+        self.created_at = datetime.utcnow().isoformat()
+        self.updated_at = self.created_at
+        self.work_dir = os.path.join(STORE_DIR, self.session_id)
+        os.makedirs(self.work_dir, exist_ok=True)
+        self.result_files: List[str] = []
+        self.error_traceback: Optional[str] = None
+
+    def to_status(self) -> SessionStatus:
+        return SessionStatus(
+            session_id=self.session_id, status=self.status, progress=round(self.progress, 3),
+            message=self.message, created_at=self.created_at, updated_at=self.updated_at,
+            result_files=[os.path.basename(p) for p in self.result_files], error_traceback=self.error_traceback
+        )
+
+    def set(self, *, status: Optional[str] = None, progress: Optional[float] = None, message: Optional[str] = None):
+        if status is not None: self.status = status
+        if progress is not None: self.progress = max(0.0, min(1.0, progress))
+        if message is not None: self.message = message
+        self.updated_at = datetime.utcnow().isoformat()
+
+
+SESSIONS: Dict[str, Session] = {}

+ 20 - 0
services/reconstructor/api/storage.py

@@ -0,0 +1,20 @@
+import os
+import shutil
+import uuid
+from fastapi import UploadFile, File, HTTPException
+from .core import STORE_DIR
+
+FILES: dict[str, str] = {}  # file_id -> abs path
+
+ALLOWED_EXTS = {".mat", ".h5", ".hdf5", ".json"}
+
+def save_upload(file: UploadFile = File(...)) -> dict:
+    ext = (os.path.splitext(file.filename or "")[1] or "").lower()
+    if ext not in ALLOWED_EXTS:
+        raise HTTPException(400, f"Поддерживаются: {', '.join(sorted(ALLOWED_EXTS))}")
+    file_id = str(uuid.uuid4())
+    save_path = os.path.join(STORE_DIR, f"{file_id}_{file.filename}")
+    with open(save_path, "wb") as f:
+        shutil.copyfileobj(file.file, f)
+    FILES[file_id] = save_path
+    return {"file_id": file_id, "filename": file.filename}

+ 32 - 0
services/reconstructor/api/utils.py

@@ -0,0 +1,32 @@
+import glob
+import os
+from typing import List
+import matplotlib.pyplot as plt
+
+class SavefigRedirect:
+    """Контекст, который перенаправляет plt.savefig('data/...') в нужную папку."""
+    def __init__(self, base_dir: str):
+        self.base_dir = base_dir
+        self._orig = None
+
+    def __enter__(self):
+        self._orig = plt.savefig
+        def _patched(fname, *args, **kwargs):
+            if isinstance(fname, str) and (fname.startswith("data\\") or fname.startswith("data/")):
+                out = os.path.join(self.base_dir, fname.replace("data\\", "data/").replace("data/", "data/"))
+                os.makedirs(os.path.dirname(out), exist_ok=True)
+                return self._orig(out, *args, **kwargs)
+            return self._orig(fname, *args, **kwargs)
+        plt.savefig = _patched
+        return self
+
+    def __exit__(self, exc_type, exc, tb):
+        if self._orig:
+            plt.savefig = self._orig
+
+def collect_outputs(work_dir: str) -> List[str]:
+    candidates = []
+    candidates += glob.glob(os.path.join(work_dir, "data", "**", "*.*"), recursive=True)
+    candidates += glob.glob(os.path.join(work_dir, "*.png"))
+    out = [p for p in sorted(set(candidates)) if os.path.isfile(p)]
+    return out

+ 135 - 0
services/reconstructor/client_reco_test.py

@@ -0,0 +1,135 @@
+import argparse
+import io
+import mimetypes
+import os
+import time
+import zipfile
+
+import requests
+
+
+def upload(base_url: str, path: str) -> str:
+    url = f"{base_url}/upload"
+    filename = os.path.basename(path)
+    mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
+    with open(path, "rb") as f:
+        files = {"file": (filename, f, mime)}
+        r = requests.post(url, files=files, timeout=60)
+    r.raise_for_status()
+    file_id = r.json()["file_id"]
+    return file_id
+
+
+def start_session(
+        base_url: str,
+        file_raw_id: str,
+        file_json_id: str,
+        sequence_name: str,
+        digit: str,
+        phase_shift: bool,
+        file_order_id: str | None = None,
+) -> str:
+    url = f"{base_url}/h5/reconstruct"
+    payload = {
+        "file_raw_id": file_raw_id,
+        "file_json_id": file_json_id,
+        "file_order_id": file_order_id,  # можно None
+        "sequence_name": sequence_name,
+        "digit": digit,
+        "phase_shift": phase_shift,
+    }
+    r = requests.post(url, json=payload, timeout=30)
+    r.raise_for_status()
+    return r.json()["session_id"]
+
+
+def poll_status(base_url: str, session_id: str, interval_s: float = 0.5) -> dict:
+    url = f"{base_url}/sessions/{session_id}"
+    last = None
+    while True:
+        r = requests.get(url, timeout=15)
+        r.raise_for_status()
+        st = r.json()
+        if st != last:
+            p = int(st.get("progress", 0) * 100)
+            msg = st.get("message", "")
+            print(f"\r[{st['status']:<7}] {p:3d}%  {msg:40s}", end="", flush=True)
+            last = st
+        if st["status"] in ("done", "error"):
+            print()
+            return st
+        time.sleep(interval_s)
+
+
+def download_and_extract(base_url: str, session_id: str, out_dir: str) -> str:
+    os.makedirs(out_dir, exist_ok=True)
+    url = f"{base_url}/sessions/{session_id}/archive.zip"
+    r = requests.get(url, timeout=120)
+    r.raise_for_status()
+    buf = io.BytesIO(r.content)
+    with zipfile.ZipFile(buf) as zf:
+        zf.extractall(out_dir)
+    return out_dir
+
+
+def main():
+    ap = argparse.ArgumentParser(description="Client for MRI Reconstruction API")
+    ap.add_argument("--url", default="http://127.0.0.1:8000", help="base URL of the API")
+    ap.add_argument("--raw", required=True, help="path to raw .mat/.h5")
+    ap.add_argument("--params", required=True, help="path to params.json")
+    ap.add_argument("--order", help="path to order.json (for nonlinear/radial)")
+    ap.add_argument("--seq", required=True,
+                    choices=["linear_decart", "nonlinear_decart(tse)", "linear_epi", "radial_propeller"])
+    ap.add_argument("--digit", required=True, choices=["2d", "3d"])
+    ap.add_argument("--phase-shift", action="store_true", help="use RF_spoil from JSON (otherwise 0)")
+    ap.add_argument("--out", default="results", help="output directory for images")
+    args = ap.parse_args()
+
+    base_url = args.url.rstrip("/")
+
+    print("Uploading files...")
+    raw_id = upload(base_url, args.raw)
+    json_id = upload(base_url, args.params)
+    order_id = upload(base_url, args.order) if args.order else None
+
+    print(f"RAW id: {raw_id}")
+    print(f"JSON id: {json_id}")
+    if order_id:
+        print(f"ORDER id: {order_id}")
+
+    print("Starting session...")
+    session_id = start_session(
+        base_url=base_url,
+        file_raw_id=raw_id,
+        file_json_id=json_id,
+        file_order_id=order_id,
+        sequence_name=args.seq,
+        digit=args.digit,
+        phase_shift=bool(args.phase_shift),
+    )
+    print(f"Session: {session_id}")
+
+    print("Waiting for completion...")
+    st = poll_status(base_url, session_id)
+
+    if st["status"] == "error":
+        print("\n--- ERROR TRACEBACK ---")
+        print(st.get("error_traceback", ""))
+        raise SystemExit(1)
+
+    out_dir = os.path.join(args.out, session_id)
+    download_and_extract(base_url, session_id, out_dir)
+
+    r = requests.get(f"{base_url}/sessions/{session_id}/files", timeout=30)
+    r.raise_for_status()
+    files = r.json().get("files", [])
+
+    print(f"\nSaved to: {os.path.abspath(out_dir)}")
+    if files:
+        print("Files:")
+        for name in files:
+            print("  ", name)
+
+
+if __name__ == "__main__":
+    main()

+ 190 - 0
services/reconstructor/reco.py

@@ -0,0 +1,190 @@
+import numpy as np
+import os
+import matplotlib
+matplotlib.use('Agg')  # headless backend — must be set before pyplot import
+import matplotlib.pyplot as plt
+import sys
+
+
+# os.chdir('C:/')
+# sys.path.append("C:/Users/iuliia/recoUI/serv")
+def reconstruction(matrix,k_space_min, k_space_max, nCoil, nContrast, nSlice, phase_shift=0, return_image=True):
+    
+    for i in range(matrix.shape[1]):
+        phase_factors = np.exp(-1j * phase_shift) ** i
+        matrix[:, i] = matrix[:, i] * phase_factors
+
+    fft_data = np.fft.ifft2(matrix)
+    fft_shifted_data = np.fft.ifftshift(fft_data)
+    amplitude = np.abs(fft_shifted_data)
+
+    plt.imshow(amplitude, cmap='gray', vmin=amplitude.min(), vmax=amplitude.max())
+    plt.colorbar()
+    plt.savefig(
+        os.path.join('data', f'im_contrast_{nCoil}_{nContrast}_k_{nSlice}.png'))
+    plt.close()
+
+    log_matrix = np.log(np.abs(matrix))
+    print(k_space_min, " ", k_space_max)
+    plt.imshow(log_matrix, cmap='gray', vmin=np.log(np.abs(k_space_min)), vmax=np.log(np.abs(k_space_max)))
+    plt.colorbar()
+    plt.savefig(
+        os.path.join('data', f'k_contrast_{nCoil}_{nContrast}_k_{nSlice}.png'))
+    plt.close()
+
+    if return_image:
+        return amplitude
+
+
+def reco_3d(matrix, maximum_k_space, minimum_k_space, nCoil, nContrast, phase_shift=0, return_image=True):
+    '''
+        :param matrix: [x,y,z]
+        :param nContrast: количество контрастов
+        :param phase_shift: угол
+        :return: 
+    '''
+
+    for i in range(matrix.shape[1]):
+        phase_factors = np.exp(-1j * phase_shift) ** i
+        matrix[:, i, :] = matrix[:, i, :] * phase_factors
+
+    fft_data = np.fft.ifftn(matrix)
+    fft_shifted_data = np.fft.ifftshift(fft_data)
+    amplitude = np.abs(fft_shifted_data)
+    # print("TTTT")
+
+    for slice in range(amplitude.shape[2]):
+        plt.imshow(amplitude[:, :, slice], cmap='gray', vmin=amplitude.min(),
+                   vmax=amplitude.max())
+        plt.colorbar()
+        plt.savefig(
+            os.path.join('data', f'im_contrast_{nCoil}_{nContrast}_k_{slice}.png'))
+        plt.close()
+
+        # print("slice")
+
+        log_matrix = np.log(np.abs(matrix))
+        plt.imshow(log_matrix[:, :, slice], cmap='gray', vmin=np.log(np.abs(minimum_k_space)),
+                   vmax=np.log(np.abs(maximum_k_space)))
+        plt.savefig(
+            os.path.join('data', f'k_contrast_{nCoil}_{nContrast}_k_{slice}.png'))
+        plt.close()
+
+    if return_image:
+        return amplitude
+
+
+def save_ffft(data, spoil=0):
+    '''
+        :param data: [x, y, z, contrast, coil]
+        :param spoil: градус сдвига
+    '''
+    print(data.shape)
+
+    k_space_max = np.max(data)
+    k_space_min = np.min(data)
+
+    num_slice = data.shape[2]  # Размер третьего измерения slice
+    num_contrast = data.shape[3]  # Размер четвертого измерения contrast
+    num_coils = data.shape[4]  # Размер по оси coil
+
+    all_img = np.zeros((num_contrast, num_slice, data.shape[0], data.shape[1]), dtype=np.float32)
+
+    for number_matrix_k_space in range(num_slice):
+        for contrast_nb in range(num_contrast):
+            img_sum = None
+
+            for coil_nb in range(num_coils):
+                coil_data = data[:, :, number_matrix_k_space, contrast_nb, coil_nb]
+                img_coil = reconstruction(coil_data, k_space_min, k_space_max,
+                                          coil_nb, contrast_nb, number_matrix_k_space,
+                                          spoil,
+                                          return_image=True )
+
+                img_abs_two = img_coil ** 2
+
+                if img_sum is None:
+                    img_sum = img_abs_two
+                else:
+                    img_sum += img_abs_two
+
+            all_img[contrast_nb, number_matrix_k_space] \
+                = img_sum.copy()  # тут сохраняется ИЗОБРАЖЕНИЕ, квадрат модуля для ВСЕХ катушек данного констраста и слоя
+
+    global_max = np.max(all_img)
+    global_min = np.min(all_img)
+
+    save_img(all_img, global_max, global_min)
+
+
+def save_ffft_3d(data, phase=0):
+    '''
+        :param data:  [x, y, z, contrast, coil]
+        :param phase: spoil
+    '''
+    num_contrast = data.shape[3]  # Размер четвертого измерения
+    num_coils = data.shape[4]
+    num_slice = data.shape[2]
+
+    k_space_max = np.max(data)
+    k_space_min = np.min(data)
+
+    all_img = np.zeros((num_contrast, num_slice, data.shape[0], data.shape[1]), dtype=np.float32)
+
+    # print("phase: ", phase)
+    # for coil_nb in range(num_coils):
+    for contrast_nb in range(num_contrast):
+        img_sum = None
+
+        for coil_nb in range(num_coils):
+            coil_data = data[:, :, :, contrast_nb, coil_nb]
+            img_coil = reco_3d(coil_data, k_space_max, k_space_min,
+                               coil_nb, contrast_nb,
+                               phase,
+                               return_image=True)
+            
+            print(img_coil.shape)
+
+            img_abs_two = img_coil ** 2
+
+            if img_sum is None:
+                img_sum = img_abs_two
+            else:
+                img_sum += img_abs_two
+
+
+        img_sum = np.transpose(img_sum, (2, 0, 1))
+
+        all_img[contrast_nb] \
+            = img_sum.copy()  # тут сохраняется ИЗОБРАЖЕНИЕ, квадрат модуля для ВСЕХ катушек данного констраста
+        
+        print(contrast_nb)
+        print(all_img.shape)
+
+    # all_img = np.transpose(all_img, (1, 2, 3, 0))
+
+    global_max = np.max(all_img)
+    global_min = np.min(all_img)
+    
+    print(global_max, global_min)
+    print(all_img)
+
+    save_img(all_img, global_max, global_min)
+
+
+def save_img(data, maximum, minimum):
+    '''
+        :param data: all_img[contrast_nb, number_matrix_k_space] = img_sum.[x,y] = [contrast, slice, x, y] 
+        :return: 
+    '''
+    
+    contrasts = data.shape[0]
+    slices = data.shape[1]
+
+    for contrast in range(contrasts):
+        for slice in range(slices):
+            plt.imshow(data[contrast, slice], cmap='gray', vmin=minimum, vmax=maximum)
+            plt.colorbar()
+            plt.savefig(
+                os.path.join('data', f'im_{contrast}_k_{slice}.png'))
+            plt.close()

+ 211 - 0
services/reconstructor/reconsrtuctionApp.py

@@ -0,0 +1,211 @@
+import json
+import h5py
+import sys
+# sys.path.append("C:/Users/iuliia/recoUI/serv")
+from traj import *
+from reco import *
+from seqData import SequenceDataFrame
+from service import ReconstructionService
+
+
+class ReconstructionApp():
+
+    def __init__(self, name, digit, shift):
+        super().__init__()
+        self.name = name  # type seq (linear|non|epi|radial)
+        # print("self.name", self.name)
+        self.digit = digit  # 2|3 d
+        self.phase_shift = shift  # bool
+
+    def start_reconstruction(self, path_raw_data, path_np_data_json, path_order_json):
+        try:
+            service = ReconstructionService()
+
+            nX, nY, nZ, d_scans, nContr, spoil, ETL, N_TE, phi_wing, N_wings = self.read_consts(path_np_data_json)
+            # print(nX, nY, nZ, d_scans, nContr, spoil)
+            service.path_raw_data = path_raw_data
+
+            raw_data = service.read_mat_file()
+            sequence_data = SequenceDataFrame()
+            sequence_data.read_raw_data(raw_data)
+            sequence_data.read_dots(int(nX))
+            sequence_name = self.name  # Получаем введенное имя последовательности
+
+            # print(sequence_name, path_order_json)
+
+            if sequence_name == 'nonlinear_decart(tse)' or sequence_name == 'radial_propeller':
+                print(path_order_json)
+
+                k_space_order = self.read_order_from_json(path_order_json)
+                print(f"Загружен порядок k-space: {k_space_order}")
+
+                self.k_space_order = k_space_order
+
+            arr_mat = np.array(sequence_data.mat_df['dot'])
+            # print("len_arr_raw_data: ", len(arr_mat))
+
+            # print(sequence_name)
+            digit = self.digit  # Получаем 2d или 3d
+            # print("digit:", self.digit)
+
+            if not path_raw_data or not path_np_data_json:
+                raise ValueError("Пожалуйста, выберите файлы и введите данные перед началом реконструкции.")
+
+            else:
+                json_data = path_np_data_json
+
+                if digit == '2d':
+                    # print("@ @ 2d @ @")
+                    self.reconstruct_2d(sequence_name, arr_mat, json_data)
+                elif digit == '3d':
+                    # print("@ @ 3d @ @")
+                    self.reconstruct_3d(sequence_name, arr_mat, json_data)
+
+        except ValueError as ve:
+            raise ValueError(str(ve))
+
+        except NotImplementedError as nie:
+            raise NotImplementedError(str(nie))
+
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")
+
+    def read_order_from_json(self, path_order_json):
+        with open(path_order_json, 'r') as f:
+            data = json.load(f)
+
+        # Извлечение массива из ключа "k_space_order"
+        k_space_order = data.get("k_space_order", [])
+        return k_space_order
+
+    def read_mat_file(self, path):
+        with h5py.File(path, 'r') as f:
+            key = list(f.keys())[0]
+
+        with h5py.File(path, 'r') as f:
+            data = f[key][:]
+
+        data = np.array(data)
+
+        return data
+
+    def get_value_or_default(self, json_obj, key, default_value=1):
+        return int(json_obj.get(key, default_value))
+
+    def read_consts(self, path):
+        with open(path, 'r') as file:
+            data = json.load(file)
+
+        # d_scans = int(data['D_scans'])
+        d_scans = self.get_value_or_default(data, "D_scans", 1)
+        Np = int(data['Np'])
+        Nf = int(data['Nf'])
+        sl_nb = int(data['sl_nb'])
+        # nContrast = int(data['contrasts'])
+        nContrast = self.get_value_or_default(data, "contrasts", 1)
+        # spoil = float(data['RF_spoil'])
+        spoil = self.get_value_or_default(data, "RF_spoil", 0)
+
+        ETL = self.get_value_or_default(data, 'ETL', 0)  # TSE
+        N_TE = self.get_value_or_default(data, 'N_TE', 0)  # TSE
+
+        phi_wing = self.get_value_or_default(data, "phi_wing", 0)  # radial
+        N_wings = self.get_value_or_default(data, "N_wings", 0)  # radial
+
+        return Np, Nf, sl_nb, d_scans, nContrast, spoil, ETL, N_TE, phi_wing, N_wings
+
+    def read_consts_3d(self, path):
+        with open(path, 'r') as file:
+            data = json.load(file)
+
+        d_scans = self.get_value_or_default(data, "D_scans", 1)
+        # d_scans = int(data['D_scans'])
+        Np = int(data['Np'])
+        Nf = int(data['Nf'])
+        # sl_nb = self.get_value_or_default(data, "Nss", 1)
+        sl_nb = int(data['Nss'])
+        if sl_nb % 2 == 1:
+            sl_nb -= 1
+        # nContrast = int(data['contrasts'])
+        nContrast = self.get_value_or_default(data, "contrasts", 1)
+        # spoil = float(data['RF_spoil'])
+        spoil = self.get_value_or_default(data, "RF_spoil", 0)
+
+        return Np, Nf, sl_nb, d_scans, nContrast, spoil
+
+    def reconstruct_2d(self, sequence_name, mat_data, json_data):
+        try:
+            nX, nY, nZ, d_scans, nContr, spoil, ETL, N_TE, phi_wing, N_wings = self.read_consts(json_data)
+            # print(spoil)
+
+            if self.phase_shift == False:
+                spoil = 0
+
+            '''
+                all_data = k_space[x, y, z, contrast, coil] - собрано в правильном порядке
+            '''
+            if sequence_name == "linear_decart":
+                # print("ll")
+                all_data = gather_data_along_trajectory(mat_data, nX, nY, nZ, nContr)
+                # print("all_data")
+
+                save_ffft(all_data, spoil)
+
+            elif sequence_name == "nonlinear_decart(tse)":
+                all_data = gather_data_along_trajectory_nonlinear(mat_data, self.k_space_order, nX, nY, nZ, nContr)
+                save_ffft(all_data, spoil)
+
+            elif sequence_name == "linear_epi":
+                all_data = gather_data_along_trajectory_linear_epi(mat_data, nX, nY, nZ, nContr)
+                save_ffft(all_data, spoil)
+
+            elif sequence_name == "radial_propeller":
+                print("RADIAL")
+                all_data = gather_data_along_trajectory_radial(mat_data, self.k_space_order, nX, nY, nZ, phi_wing,
+                                                               N_wings)
+                save_ffft(all_data, spoil)
+
+            else:
+                raise NotImplementedError("Для данного типа ИП еще не добавлена функция.")
+                # QMessageBox.critical(self, "Ошибка: для данного типа ИП еще не добавлена функция")
+        except ValueError as ve:
+            raise ValueError(str(ve))
+
+        except NotImplementedError as nie:
+            raise NotImplementedError(str(nie))
+
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")
+
+    def reconstruct_3d(self, sequence_name, mat_data, json_data):
+        try:
+            nX, nY, nZ, d_scans, nContr, spoil = self.read_consts_3d(json_data)  # Nss = nSlice
+
+            if self.phase_shift == False:
+                spoil = 0
+
+            '''
+                all_data = [x, y, z, contrast, coil]
+            '''
+
+            if sequence_name == "linear_decart":
+                all_data = gather_data_along_trajectory(mat_data, nX, nY, nZ, nContr)
+                save_ffft_3d(all_data, spoil)
+
+            elif sequence_name == "nonlinear_decart(tse)":
+                all_data = gather_data_along_trajectory_nonlinear(mat_data, self.k_space_order, nX, nY, nZ, nContr)
+                save_ffft_3d(all_data, spoil)
+
+            else:
+                raise NotImplementedError(
+                    "Для данного типа ИП еще не добавлена функция.")  # TODO уже можно добавлять функцию, так как она работает
+                # QMessageBox.critical(self, "Ошибка: для данного типа ИП еще не добавлена функция")
+
+        except ValueError as ve:
+            raise ValueError(str(ve))
+
+        except NotImplementedError as nie:
+            raise NotImplementedError(str(nie))
+
+        except Exception as e:
+            raise RuntimeError(f"Произошла ошибка в процессе реконструкции: {e}")

+ 7 - 0
services/reconstructor/requirements.txt

@@ -0,0 +1,7 @@
+fastapi>=0.100
+uvicorn[standard]>=0.20
+numpy>=1.24
+scipy>=1.10
+matplotlib>=3.7
+h5py>=3.8
+python-multipart>=0.0.6

+ 36 - 0
services/reconstructor/seqData.py

@@ -0,0 +1,36 @@
+import pandas as pd
+import numpy as np
+import sys
+# sys.path.append("C:/Users/iuliia/recoUI/serv")
+
+class SequenceDataFrame:
+    array_raw = None
+    mat_df = None
+    seq_df = None
+    all_df = None
+    sequence_seq = None
+    type_seq = None
+    phase_shift = None
+
+    def read_raw_data(self, data):
+        sub_arrays = [subarray for subarray in data]
+        self.array_raw = sub_arrays[1]
+
+    def read_dots(self, Np):
+        mat_df = pd.DataFrame(self.array_raw, columns=['real', 'imag'])
+        mat_df = mat_df.drop(mat_df.index[Np::(Np + 1)])
+        mat_df = mat_df.reset_index(drop=True)
+        mat_df = mat_df.dropna()
+
+        columns = [mat_df.iloc[:, i:i + 2] for i in range(0, mat_df.shape[1], 2)]
+        mat_df = pd.concat(columns, axis=1)
+        mat_df['dot'] = mat_df['real'] + 1j * mat_df['imag']
+        mat_df = mat_df[~mat_df['dot'].isna() & (mat_df['dot'] != '')]
+
+        num_batches = len(mat_df) // Np
+        index_values = np.repeat(np.arange(num_batches), Np)
+        index_values = index_values[:len(mat_df)]
+
+        mat_df['index'] = index_values
+
+        self.mat_df = mat_df

+ 31 - 0
services/reconstructor/servUI.py

@@ -0,0 +1,31 @@
+import sys
+from PyQt5.QtWidgets import QApplication
+import sys
+# sys.path.append("C:/Users/iuliia/recoUI/serv")
+
+
+from startApp import *
+from reconsrtuctionApp import *
+
+path_np_data_json = None
+path_raw_data = None
+path_seq_json = None
+path_order_json = None
+
+# TODO добавить кнопку на стартовую панель с выбором надо ли смещать по фазе или нет (так как разные версии комы)
+
+# print("Start")
+
+app = QApplication(sys.argv)
+ex = StartApp()
+ex.setWindowTitle("Реконструкция")
+ex.show()
+sys.exit(app.exec_())
+
+
+# print("YES START")
+
+path_np_data_json = start_window.path_np_data_json
+path_raw_data = start_window.path_raw_data
+order_parameters_window = start_window.json_window
+path_order_json = order_parameters_window.path_order_json

+ 45 - 0
services/reconstructor/service.py

@@ -0,0 +1,45 @@
+import numpy as np
+import h5py
+import pandas as pd
+import sys
+# sys.path.append("C:/Users/iuliia/recoUI/serv")
+np.set_printoptions(threshold=np.inf)
+
+
+class ReconstructionService:
+
+    path_np_data_json = None
+    path_raw_data = None
+    path_seq_json = None
+
+    def open_file_read_data(self):  # data param
+        with open(self.path_np_data_json, 'r') as f:
+            data = pd.read_json(f, typ='series')
+        return data
+
+    def read_consts(self, data):  # consts in data param: slice | np | nf
+        df = pd.DataFrame(data)
+        d_scans = int(df.loc['D_scans', 0])
+        Np = int(df.loc['Np', 0])
+        Nf = int(df.loc['Nf', 0])
+        sl_nb = int(df.loc['sl_nb', 0])
+
+        # ETL = int(df.loc['ETL', 0])  # for tse
+        # N_TE = df.loc['N_TE', 0]
+        #
+        # phi_wing = df.loc['phi_wing', 0]   # for radial
+        # N_wings = df.loc['N_wings', 0]
+
+        return (Np, Nf, sl_nb, d_scans)
+                # , ETL, N_TE, phi_wing, N_wings)
+
+    def read_mat_file(self):
+        with h5py.File(self.path_raw_data, 'r') as f:
+            key = list(f.keys())[0]
+
+        with h5py.File(self.path_raw_data, 'r') as f:
+            data = f[key][:]
+
+        data = np.array(data)
+
+        return data

+ 159 - 0
services/reconstructor/startApp.py

@@ -0,0 +1,159 @@
+from PyQt5.QtWidgets import QWidget, QLabel, QPushButton, QVBoxLayout, QFileDialog, QComboBox, QCheckBox
+import sys
+# sys.path.append("C:/Users/iuliia/recoUI/serv")
+from jsonSelectionWindow import JsonSelectionWindow
+from reconsrtuctionApp import ReconstructionApp
+
+
+class StartApp(QWidget):
+
+    def __init__(self):
+        super().__init__()
+        # print("TUT")
+        self.path_np_data_json = None
+        self.path_raw_data = None
+        self.path_seq_json = None
+        self.path_order_json = None
+        self.reconstruction_app = None
+
+        # print("Инициализация окна...")
+        self.initUI()
+        # print("Окно инициализировано.")
+
+    def initUI(self):
+
+        self.setWindowTitle('Реконструкция')
+
+        layout = QVBoxLayout()
+
+        self.digit_label = QLabel("Выберите (2d или 3d)")
+        layout.addWidget(self.digit_label)
+        self.digit_input = QComboBox(self)
+        self.digit_input.addItems(['2d', '3d'])
+        self.digit_input.currentTextChanged.connect(self.update_sequence_options)
+        layout.addWidget(self.digit_input)
+
+        self.sequence_label = QLabel("Имя последовательности")
+        layout.addWidget(self.sequence_label)
+        self.sequence_input = QComboBox(self)
+        layout.addWidget(self.sequence_input)
+
+        self.update_sequence_options(self.digit_input.currentText())
+
+        # print(self.sequence_input.currentText(),self.digit_input.currentText())
+
+        self.mat_file_button = QPushButton("Загрузить .mat файл", self)
+        self.mat_file_button.clicked.connect(self.load_mat_file)
+        layout.addWidget(self.mat_file_button)
+
+        self.mat_file_label = QLabel("Файл .mat не выбран", self)
+        layout.addWidget(self.mat_file_label)
+
+        self.json_file_button = QPushButton("Загрузить .json файл", self)
+        self.json_file_button.clicked.connect(self.load_json_file)
+        layout.addWidget(self.json_file_button)
+
+        self.json_file_label = QLabel("Файл .json не выбран", self)
+        layout.addWidget(self.json_file_label)
+
+
+        ####################################################
+
+        self.open_order_json = QPushButton("Открыть окно выбора JSON", self)
+        self.open_order_json.clicked.connect(self.open_json_selection_window)
+        layout.addWidget(self.open_order_json)
+
+        # print(self.sequence_input.currentText(),self.digit_input.currentText())
+        # self.reconstruction_app = ReconstructionApp(self.sequence_input.currentText(), self.digit_input.currentText())
+        # self.path_order_json = self.json_window.path_order_json
+
+        self.phase_shift_checkbox = QCheckBox("Сдвиг по фазе (on/off)", self)
+        self.phase_shift_checkbox.setChecked(False)  # По умолчанию отключен
+        layout.addWidget(self.phase_shift_checkbox)
+
+
+        self.start_reco = QPushButton("Начать реконструкцию", self)
+        self.start_reco.clicked.connect(self.init_reconstruction)
+
+        layout.addWidget(self.start_reco)
+
+
+        self.setGeometry(300, 300, 600, 500)
+        self.setLayout(layout)
+
+
+    def update_sequence_options(self, dimension):
+        self.sequence_input.clear()
+
+        if dimension == '2d':
+            self.sequence_input.addItems([
+                'linear_decart',
+                'nonlinear_decart(tse)',
+                'linear_epi',
+                'radial_propeller',
+                'linear_decat_readout'
+            ])
+        elif dimension == '3d':
+            self.sequence_input.addItems([
+                'linear_decart',
+                'nonlinear_decart(tse)'
+            ])
+
+    def load_mat_file(self):
+        mat_file, _ = QFileDialog.getOpenFileName(self, "Выбрать .mat файл", "", "MAT Files (*.mat)")
+        if mat_file:
+            self.mat_file_label.setText(f"Загружен .mat файл: {mat_file}")
+        else:
+            self.mat_file_label.setText("Файл .mat не выбран")
+
+        self.path_raw_data = mat_file
+
+    def load_json_file(self):
+        json_file, _ = QFileDialog.getOpenFileName(self, "Выбрать .json файл", "", "JSON Files (*.json)")
+        if json_file:
+            self.json_file_label.setText(f"Загружен .json файл: {json_file}")
+        else:
+            self.json_file_label.setText("Файл .json не выбран")
+
+        self.path_np_data_json = json_file
+
+    def open_json_selection_window(self):
+        self.json_window = JsonSelectionWindow(
+            self.path_np_data_json, self.path_raw_data,
+            self.sequence_input.currentText(), self.digit_input.currentText())
+
+        self.path_order_json = self.json_window.path_order_json
+        # print(self.path_order_json)
+        self.json_window.show()
+        self.json_window.closeEvent = lambda event: self.update_order_json_path()
+        # print(f"Изначальный путь JSON порядка: {self.path_order_json}")
+
+    def update_order_json_path(self):
+        self.path_order_json = self.json_window.path_order_json
+        # print(f"Путь к JSON файлу порядка после выбора: {self.path_order_json}")
+
+    # def start_reconstruction(self):
+
+    def init_reconstruction(self):
+        if self.phase_shift_checkbox.isChecked():
+            # print("Флажок установлен: включён сдвиг по фазе.")
+            # Добавьте действия, если флажок установлен
+            phase_shift_enabled = True
+        else:
+            # print("Флажок не установлен: сдвиг по фазе выключен.")
+            # Добавьте действия, если флажок не установлен
+            phase_shift_enabled = False
+
+        self.reconstruction_app = ReconstructionApp(
+            self.sequence_input.currentText(),
+            self.digit_input.currentText(),
+            phase_shift_enabled
+        )
+        self.reconstruction_app.start_reconstruction(
+            self.path_raw_data, self.path_np_data_json, self.path_order_json
+        )
+
+
+
+
+

+ 116 - 0
services/reconstructor/test.py

@@ -0,0 +1,116 @@
+import importlib
+import io
+import json
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture()
+def client(tmp_path, monkeypatch):
+    app_mod = importlib.import_module("app")
+
+    app_mod.STORE_DIR = str(tmp_path)
+    os.makedirs(app_mod.STORE_DIR, exist_ok=True)
+
+    class DummyRecoApp:
+        def __init__(self, name, digit, shift):
+            self.name = name
+            self.digit = digit
+            self.shift = shift
+
+        def start_reconstruction(self, path_raw_data, path_np_data_json, path_order_json):
+            import matplotlib.pyplot as plt
+            plt.figure()
+            plt.plot([0, 1], [0, 1])
+            plt.title(f"{self.name}-{self.digit}")
+            plt.savefig("data/test.png")
+            plt.close()
+
+    monkeypatch.setattr(app_mod, "ReconstructionApp", DummyRecoApp, raising=True)
+
+    return TestClient(app_mod.app)
+
+
+def _upload(client: TestClient, name: str, content: bytes, mime: str = "application/octet-stream"):
+    files = {"file": (name, io.BytesIO(content), mime)}
+    r = client.post("/upload", files=files)
+    assert r.status_code == 200, r.text
+    obj = r.json()
+    assert "file_id" in obj
+    return obj["file_id"]
+
+
+def test_upload_json(client: TestClient):
+    params = {
+        "Np": 8, "Nf": 8, "sl_nb": 1,
+        "contrasts": 1, "RF_spoil": 0,
+        "ETL": 0, "N_TE": 0, "phi_wing": 0, "N_wings": 0,
+        "D_scans": 1
+    }
+    fid = _upload(client, "params.json", json.dumps(params).encode("utf-8"), "application/json")
+    assert isinstance(fid, str) and len(fid) > 0
+
+
+def test_upload_h5(client: TestClient):
+    fake = b"\x89HDF\r\n\x1a\n" + b"\x00" * 64
+    fid = _upload(client, "raw.h5", fake, "application/octet-stream")
+    assert isinstance(fid, str) and len(fid) > 0
+
+
+def test_full_reconstruction_flow(client: TestClient, tmp_path):
+    raw_bytes = b"\x89HDF\r\n\x1a\n" + b"\x00" * 64
+    raw_id = _upload(client, "raw.h5", raw_bytes)
+
+    params = {
+        "Np": 8, "Nf": 8, "sl_nb": 1,
+        "contrasts": 1, "RF_spoil": 0,
+        "ETL": 0, "N_TE": 0, "phi_wing": 0, "N_wings": 0,
+        "D_scans": 1
+    }
+    json_id = _upload(client, "params.json", json.dumps(params).encode("utf-8"), "application/json")
+
+    payload = {
+        "file_raw_id": raw_id,
+        "file_json_id": json_id,
+        "file_order_id": None,
+        "sequence_name": "linear_decart",
+        "digit": "2d",
+        "phase_shift": True
+    }
+    r = client.post("/reconstruct", json=payload)
+    assert r.status_code == 200, r.text
+    job = r.json()
+    job_id = job["job_id"]
+
+    for _ in range(50):
+        s = client.get(f"/jobs/{job_id}")
+        assert s.status_code == 200
+        st = s.json()
+        if st["status"] in ("done", "error"):
+            break
+
+    assert st["status"] == "done", f"job failed: {st.get('error_traceback')}"
+
+    lf = client.get(f"/jobs/{job_id}/files")
+    assert lf.status_code == 200
+    files = lf.json()["files"]
+    assert any(name.endswith(".png") for name in files), f"no png files in {files}"
+
+    dz = client.get(f"/jobs/{job_id}/archive.zip")
+    assert dz.status_code == 200
+    assert dz.headers.get("content-type") == "application/zip"
+
+
+def test_reconstruct_bad_ids(client: TestClient):
+    payload = {
+        "file_raw_id": "nope",
+        "file_json_id": "nope",
+        "file_order_id": None,
+        "sequence_name": "linear_decart",
+        "digit": "2d",
+        "phase_shift": False
+    }
+    r = client.post("/reconstruct", json=payload)
+    assert r.status_code == 400

+ 200 - 0
services/reconstructor/traj.py

@@ -0,0 +1,200 @@
+# from PyQt5.QtWidgets.QWidget import sizeHint
+from scipy.ndimage import rotate, map_coordinates
+import numpy as np
+from scipy.special.cython_special import inv_boxcox
+
+'''
+    тут собираются по различным траекториям несортированные данные в к-пространство
+    
+    на выходе хочется получать 5-мерный массив [x, y, z, contrast, coil], чтобы дальнейшие взаимодействие происходило одинаково
+'''
+
+
+def gather_data_along_trajectory(mat_data, nX, nY, nZ, nContr, nCoils=1):
+    # out: [x, y, z, contrast, coil]
+
+    gathered_data = np.zeros((nX, nY, nZ, nContr, nCoils), dtype=np.complex128)
+
+    i = 0
+    for coil in range(nCoils):
+        for y in range(0, nY):
+            for z in range(0, nZ):
+                for contrast in range(0, nContr):
+                    for x in range(0, nX):
+                        gathered_data[x, y, z, contrast, coil] = mat_data[i]
+                        i += 1
+    return gathered_data
+
+
+def gather_data_along_trajectory_nonlinear(mat_data, order, nX, nY, nZ, nContr, nCoils=1):
+    # out: [x, y, z, contrast, coil]
+
+    gathered_data = np.zeros((nX, nY, nZ, nContr, nCoils), dtype=np.complex128)
+
+    N_center = (nY - 1) // 2
+
+    if nY % 2 == 0:
+        N_center += 1
+
+    # print('N_center', N_center)
+    step_y = order
+
+    # print("STEP TSE", step_y)
+
+    i = 0
+    print(len(step_y))
+    index_y = len(step_y)
+    for coil in range(nCoils):
+        for z in range(0, nZ):
+            for ind in range(index_y):
+                # for z in range(0, nZ):
+                    for y in step_y[ind]:
+                        # print(y, "step", step_y[ind])
+                        if not isinstance(y, str):
+                            # for z in range(0, nZ):
+                                for x in range(0, nX):
+                                    gathered_data[x, y, z, 0, coil] = mat_data[i]
+                                    i += 1
+
+                    # print(x, " ", y, " ", z)
+    # print("TET")
+    return gathered_data
+
+
+def gather_data_along_trajectory_linear_epi(mat_data, nX, nY, nZ, nContr, nCoils=1):
+    # out: [x, y, z, contrast, coil]
+
+    gathered_data = np.zeros((nX, nY, nZ, nContr, nCoils), dtype=np.complex128)
+
+    i = 0
+    for coil in range(nCoils):
+        for z in range(0, nZ):
+            for y in range(0, nY):
+                for contrast in range(0, nContr):
+                    if y % 2 == 1:
+                        for x in range(0, nX):
+                            gathered_data[y, x, z, contrast, coil] = mat_data[i]
+                            i += 1
+                    else:
+                        for x in range(nX - 1, -1, -1):
+                            gathered_data[y, x, z, contrast, coil] = mat_data[i]
+                            i += 1
+
+    return gathered_data
+
+
+def gather_data_along_trajectory_radial(mat_data, order, nX, nY, nZ, phi_wing, N_wings, nCoils=1):
+    # out: [x, y, z, contrast, coil]
+    print("traj_01")
+
+    k_space_not_rotate = read_raw_data_radial(mat_data, order, nX, N_wings, nCoils)
+    print("traj_02")
+    k_space = rotate_radial(k_space_not_rotate, phi_wing, N_wings)
+    print("traj_03")
+    k_spaces_with_coils = np.stack(k_space, axis=0)
+    print("traj_04")
+    # k_space = interpolation_radial(arr_k_spaces)
+    k_spaces_with_contrast = np.expand_dims(k_spaces_with_coils, axis=1)
+    print("traj_05")
+
+    k_spaces = np.transpose(k_spaces_with_contrast, (3, 4, 2, 1, 0))
+    print(k_spaces.shape)
+
+    return k_spaces
+
+
+##########################################  RADIAL  ###############################################################
+def rotate_radial(k_space_not_rotate_slices, phi_wing, N_wings):
+    # k_space_not_rotate_slices = [coil, slice, n_wing, x, y]
+
+    # print("ROTATE")
+    k_spaces = []
+
+    # result = []
+    # result_slices = []
+    # ind = 0
+    # print(len(k_space_not_rotate_slices)) # 4
+
+    for coil in range(k_space_not_rotate_slices.shape[0]):
+        result = []
+        result_slices = []
+        for iSlice in range(k_space_not_rotate_slices.shape[1]):
+            # print("iSlice: ", iSlice)
+            # k_space_not_rotate = k_space_not_rotate_slices[iSlice]
+            k_space_not_rotate = k_space_not_rotate_slices[coil, iSlice]
+            ind = 0
+            for i in range(0, phi_wing * N_wings, phi_wing):
+                # print(ind)
+                result.append(rotate(k_space_not_rotate[ind], angle=i, reshape=False, order=2))
+                ind += 1
+            result_slices.append(result.copy())
+        k_space = interpolation_radial(result_slices)
+        k_spaces.append(k_space.copy())
+        print("len ",len(result))
+
+    return k_spaces
+
+
+def read_raw_data_radial(mat_data, order, nX, N_wings, nCoils):
+    # out: [coil, slice, n_wing, x, y]
+
+    # print(len(mat_data), " ", N_wings, " ", order)
+
+    nSlices = int(len(mat_data) / N_wings / len(order[0]) / nX / nCoils)
+    k_space_size = nX
+
+    k_space = np.zeros((nCoils, nSlices, N_wings, k_space_size, k_space_size), dtype=np.complex128)
+
+    center_k_space = k_space_size // 2  # положение по Х, центр меняться не будет, можно учитывать как Мх
+    step_lines = order
+
+    # k_spaces_not_rotate = []
+
+    ind = 0
+    for coil in range(nCoils):
+        # for iSlice in range(nSlices):
+        for blade_number in range(0, N_wings, 1):
+            for iSlice in range(nSlices):
+
+                for arr_line_number in step_lines:
+                    # print(arr_line_number, "-arr_line_number")
+                    for line_number in arr_line_number:
+                        # print(line_number, "-line_number")
+                        if not isinstance(line_number, str):
+                            for index in range(nX):
+                                # print(index, "-index")
+                                radius = center_k_space + line_number - (len(arr_line_number) // 2)
+                                k_space[coil, iSlice, blade_number, radius, index] = mat_data[ind]
+                                ind += 1
+
+                # k_spaces_not_rotate.append(k_space.copy())
+            # k_space_with_slices[iSlice].append(k_spaces_not_rotate.copy())
+
+    # print("k_space: ", len(k_space))
+    return k_space
+
+
+def interpolation_radial(arr_k_spaces_slices):
+    # print("INTERPOL")
+    result_slices = []
+
+    for iSlice in range(len(arr_k_spaces_slices)):
+        arr_k_spaces = arr_k_spaces_slices[iSlice]
+
+        max_shape = tuple(np.max([arr.shape for arr in arr_k_spaces], axis=0))
+
+        result = np.zeros(max_shape, dtype=np.complex128)
+
+        for array in arr_k_spaces:
+            scale_factors = [float(s1) / s2 for s1, s2 in zip(max_shape, array.shape)]
+            grid = [np.linspace(0, dim - 1, num=int(dim * sf))[:max_shape[i]] for i, (dim, sf) in
+                    enumerate(zip(array.shape, scale_factors))]
+            coords = np.meshgrid(*grid, indexing="ij")
+
+            interpolated_array = map_coordinates(array, coords, order=1, mode="nearest")
+            result += np.nan_to_num(interpolated_array)
+
+        result_slices.append(result.copy())
+
+        # print(result)
+    return result_slices

+ 32 - 0
services/seq-interp/Dockerfile

@@ -0,0 +1,32 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+ARG APP_VERSION=dev
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1 \
+    MPLBACKEND=Agg \
+    PYTHONPATH=/app \
+    SERVICE_PORT=7475 \
+    APP_VERSION=${APP_VERSION}
+
+LABEL org.opencontainers.image.title="srv-seq-interp" \
+      org.opencontainers.image.version="${APP_VERSION}"
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    libgomp1 tk \
+    && rm -rf /var/lib/apt/lists/*
+
+# Build context is the monorepo root (docker-compose context: .)
+COPY services/seq-interp/requirements.docker.txt /app/requirements.txt
+RUN pip install --no-cache-dir -r /app/requirements.txt
+
+COPY libs/lf-scanner /app/LF_scanner
+COPY services/seq-interp /app/seq_interp
+
+WORKDIR /app/seq_interp
+
+EXPOSE 7475
+
+CMD ["sh", "-c", "uvicorn api:app --host 0.0.0.0 --port ${SERVICE_PORT}"]

+ 53 - 0
services/seq-interp/POST_request_mes.txt

@@ -0,0 +1,53 @@
+{
+    "info": {
+        "infostr": "100_avg_p1_2925_HEAD",
+        "engine": "DefaultEngine",
+        "time": "2025-12-09 14:30",
+        "iadc": {
+            "device_model": "PS4000A",
+            "srate": 8000000,
+            "points": [10000000],
+            "n_channels": 3,
+            "channel_ranges": [5, 5, 5],
+            "n_triggers": 1,
+            "averaging": 100,
+     "averaging_delay": 10,
+            "trigger_channel": 2,
+            "trig_direction": 0,
+            "threshold": 5000,
+            "auto_measure_time": 10000,
+            "enabled": true
+        },
+        "isync": {
+            "device_model": "DuePP",
+            "file": "C:/LF_MRI/test_21_02_25/Sync_param_test_21_02_2025.xml",
+            "port": 7
+        },
+        "isdr": {
+            "device_model": "HackRF",
+            "srate": 2000000,
+            "freq": 2950000,
+            "ampl": true,
+            "gain": 15,
+            "file": "C:/LF_MRI/test_21_02_25/test_21_02_2025_2Mbps_20ms_zeros.bin"
+        },
+        "igrax": {
+            "device_model": "GRU",
+            "ip": "127.0.0.1",
+            "file": "test.txt",
+            "enabled": false
+        },
+        "igray": {
+            "device_model": "GRU",
+            "ip": "127.0.0.1",
+            "file": "test.txt",
+            "enabled": false
+        },
+        "igraz": {
+            "device_model": "GRU",
+            "ip": "127.0.0.1",
+            "file": "test.txt",
+            "enabled": false
+        }
+    }
+}

+ 1 - 0
services/seq-interp/VERSION

@@ -0,0 +1 @@
+1.0.0

+ 0 - 0
services/seq-interp/__init__.py


+ 188 - 0
services/seq-interp/api.py

@@ -0,0 +1,188 @@
+"""
+seq_interp FastAPI service.
+
+Endpoints:
+  GET  /health              — liveness probe
+  POST /interpret/          — upload .seq file, run full pipeline, return task_id
+  GET  /status/             — status of all submitted tasks
+  GET  /result/{task_id}    — full result: xml_text, post_json, metadata, waveforms
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+import shutil
+from typing import Any
+
+from fastapi import FastAPI, File, HTTPException, UploadFile
+
+from src.config import config
+from src.hardware.constraints import HardwareConstraints
+from src.interfaces.pulseq_adapter import PulseqLoader
+from src.core.synchronizer import Synchronizer
+from src.interfaces.xml_generator import XMLGenerator
+from src.interfaces.rf_exporter import RFExporter
+from src.interfaces.gradient_exporter import GradientExporter
+from src.interfaces.picoscope_exporter import PicoScopeExporter
+from src.interfaces.post_request_generator import PostRequestGenerator
+
+app = FastAPI(title="seq-interp", version="1.0.0")
+
+UPLOAD_DIR = config.get("upload_dir", "data/input")
+OUTPUT_DIR = config.get("output_dir", "data/output")
+os.makedirs(UPLOAD_DIR, exist_ok=True)
+os.makedirs(OUTPUT_DIR, exist_ok=True)
+
+# task_id → {"status": str, "result": dict | None}
+_tasks: dict[str, dict] = {}
+
+_MAX_WAVEFORM_POINTS = 8000   # downsample for JSON transport
+
+
+# ── helpers ────────────────────────────────────────────────────────────────
+
+def _downsample(arr, max_pts: int) -> list:
+    """Convert numpy array to list, downsampling if too long."""
+    try:
+        import numpy as np
+        a = np.asarray(arr, dtype=float).flatten()
+        if len(a) > max_pts:
+            idx = np.linspace(0, len(a) - 1, max_pts, dtype=int)
+            a = a[idx]
+        return [float(x) for x in a]
+    except Exception:
+        return []
+
+
+def _extract_waveforms(seq_data: dict, sync_data: dict) -> dict:
+    """Extract waveform arrays from seq_data for GUI display."""
+    waveforms: dict[str, list] = {}
+    for key in ("gx", "gy", "gz", "t_gx", "t_gy", "t_gz"):
+        if key in seq_data:
+            waveforms[key] = _downsample(seq_data[key], _MAX_WAVEFORM_POINTS)
+    # RF
+    for key in ("rf_amp", "rf_phase", "t_rf"):
+        if key in seq_data:
+            waveforms[key] = _downsample(seq_data[key], _MAX_WAVEFORM_POINTS)
+    # Sync gate arrays
+    for key in ("gate_adc", "gate_rf", "gate_tr_switch", "blocks_duration"):
+        if key in sync_data:
+            waveforms[key] = _downsample(sync_data[key], _MAX_WAVEFORM_POINTS)
+    return waveforms
+
+
+async def _run_pipeline(file_path: str, task_id: str) -> None:
+    """Run the full interpretation pipeline and store result in _tasks."""
+    out_dir = os.path.join(OUTPUT_DIR, task_id)
+    os.makedirs(out_dir, exist_ok=True)
+    try:
+        hw = HardwareConstraints(json_path=config.get("hw_config_path"))
+        hw_cfg = config.hw_config
+
+        loader = PulseqLoader(hw)
+        seq_data = await asyncio.to_thread(loader.load, file_path)
+        params = seq_data.get("params", {})
+
+        sync = Synchronizer(hw)
+        sync_data = await asyncio.to_thread(sync.process, seq_data["sequence"])
+
+        xml_gen = XMLGenerator()
+        xml_path = os.path.join(out_dir, "sync_v2.xml")
+        adc_values, adc_starts = await asyncio.to_thread(
+            xml_gen.generate, sync_data, xml_path, hw
+        )
+        with open(xml_path, encoding="utf-8") as fh:
+            xml_text = fh.read()
+
+        export_tasks = [
+            asyncio.to_thread(RFExporter().export, seq_data, params, out_dir)
+        ]
+        if all(k in seq_data for k in ("gx", "gy", "gz")):
+            export_tasks.append(
+                asyncio.to_thread(GradientExporter().export, seq_data, params, out_dir)
+            )
+        iadc = hw_cfg.get("iadc", {})
+        export_tasks.append(asyncio.to_thread(
+            PicoScopeExporter().generate,
+            adc_values, adc_starts, out_dir, hw,
+            sampling_freq=iadc.get("srate", 8e6),
+            num_channels=iadc.get("n_channels", 3),
+        ))
+        await asyncio.gather(*export_tasks)
+
+        post_gen = PostRequestGenerator()
+        post_payload = post_gen.build(
+            seq_data=seq_data,
+            adc_values=adc_values,
+            sequence_path=file_path,
+            output_dir=out_dir,
+            hw_cfg=hw_cfg,
+            rf_raster_time=params.get("rf_raster_time", 1e-6),
+        )
+        post_gen.write(post_payload, out_dir)
+
+        blocks = seq_data.get("blocks", [])
+        total_s = sum(sync_data.get("blocks_duration", []))
+        adc_blocks = [b for b in blocks if b.get("has_adc")]
+
+        result: dict[str, Any] = {
+            "task_id": task_id,
+            "status": "completed",
+            "output_dir": out_dir,
+            "xml_text": xml_text,
+            "post_json": post_payload,
+            "metadata": {
+                "block_count": len(blocks),
+                "sync_block_count": sync_data.get("number_of_blocks", 0),
+                "adc_count": len(adc_blocks),
+                "adc_windows": len(adc_values),
+                "total_duration_ms": round(total_s * 1e3, 4),
+                "rf_raster_us": params.get("rf_raster_time", 1e-6) * 1e6,
+                "grad_raster_us": params.get("grad_raster_time", 1e-5) * 1e6,
+            },
+            "waveforms": _extract_waveforms(seq_data, sync_data),
+        }
+        _tasks[task_id] = {"status": "completed", "result": result}
+
+    except Exception as exc:
+        _tasks[task_id] = {"status": f"failed: {exc}", "result": None}
+
+
+# ── endpoints ────────────────────────────────────────────────────────────────
+
+@app.get("/health")
+def health():
+    return {"status": "ok"}
+
+
+@app.post("/interpret/")
+async def interpret_endpoint(file: UploadFile = File(...)):
+    """Upload a .seq file and run the full interpretation pipeline."""
+    file_path = os.path.join(UPLOAD_DIR, file.filename)
+    with open(file_path, "wb") as buf:
+        shutil.copyfileobj(file.file, buf)
+
+    task_id = os.path.splitext(file.filename)[0]
+    _tasks[task_id] = {"status": "processing", "result": None}
+    asyncio.create_task(_run_pipeline(file_path, task_id))
+    return {"status": "accepted", "task_id": task_id,
+            "message": f"Processing {file.filename}"}
+
+
+@app.get("/status/")
+def status_endpoint():
+    """Return the status of all submitted tasks."""
+    return {"tasks": {tid: v["status"] for tid, v in _tasks.items()}}
+
+
+@app.get("/result/{task_id}")
+def result_endpoint(task_id: str):
+    """Return full interpretation result (xml_text, post_json, metadata, waveforms)."""
+    entry = _tasks.get(task_id)
+    if entry is None:
+        raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found")
+    if entry["status"] == "processing":
+        raise HTTPException(status_code=202, detail="Still processing")
+    if entry["result"] is None:
+        raise HTTPException(status_code=500, detail=entry["status"])
+    return entry["result"]

+ 46 - 0
services/seq-interp/cfg/hw_config.json

@@ -0,0 +1,46 @@
+{
+  "center_freq": 2950000,
+  "sample_freq": 8000000,
+  "file_path_prefix": "C:/LF_MRI/test_21_02_25/",
+  "hw_service_url": null,
+  "iadc": {
+    "device_model": "PS4000A",
+    "srate": 8000000,
+    "n_channels": 3,
+    "channel_ranges": [5, 5, 5],
+    "n_triggers": 1,
+    "averaging": 100,
+    "averaging_delay": 10,
+    "trigger_channel": 2,
+    "trig_direction": 0,
+    "threshold": 5000,
+    "auto_measure_time": 10000,
+    "enabled": true
+  },
+  "isync": {
+    "device_model": "DuePP",
+    "port": 7
+  },
+  "isdr": {
+    "device_model": "HackRF",
+    "srate": 2000000,
+    "freq": 2950000,
+    "ampl": true,
+    "gain": 15
+  },
+  "igrax": {
+    "device_model": "GRU",
+    "ip": "127.0.0.1",
+    "enabled": false
+  },
+  "igray": {
+    "device_model": "GRU",
+    "ip": "127.0.0.1",
+    "enabled": false
+  },
+  "igraz": {
+    "device_model": "GRU",
+    "ip": "127.0.0.1",
+    "enabled": false
+  }
+}

+ 8 - 0
services/seq-interp/cfg/server_config.json

@@ -0,0 +1,8 @@
+{
+  "srv_name" : "srv_interp",
+  "log_dir": "log",
+  "upload_dir": "data/input",
+  "output_dir": "data/output",
+  "server_host": "0.0.0.0",
+  "server_port": 7475
+}

+ 10 - 0
services/seq-interp/cfg/updated_constraints_lf.json

@@ -0,0 +1,10 @@
+{
+  "RF_DELAY": 0.0005,
+  "TR_DELAY": 0.0,
+  "START_DELAY": 1.7e-05,
+  "MIN_BLOCK_DURATION": 2e-08,
+  "rf_raster_time": 1e-06,
+  "grad_raster_time": 1e-05,
+  "adc_raster_time": 1e-07,
+  "block_duration_raster": 1e-05
+}

+ 24 - 0
services/seq-interp/docs/README.md

@@ -0,0 +1,24 @@
+# MRI Sequence Interpreter
+
+## Установка:
+```bash
+pip install -r requirements.txt
+python main.py
+```
+---
+### Структура данных
+- **`data/input_sequences/`**: Хранит .seq файлы (пример: `test.seq`).
+- **`data/output_configs/`**: Содержит результаты в XML и JSON.
+
+---
+
+### Как это работает:
+1. **Загрузка данных**: `PulseqLoader` читает .seq файл.
+2. **Генерация последовательности**: `SequenceGenerator` создает Gradient Echo.
+3. **Синхронизация**: `Synchronizer` проверяет временные ограничения.
+4. **Экспорт**: Результаты сохраняются в XML через `generate_sync_xml`.
+
+**Оптимизации**:
+- Ускорение в 10-50× через Numba.
+- Четкое разделение компонентов по SOLID.
+- Автоматическая проверка временных ограничений.

+ 88 - 0
services/seq-interp/gui_app.py

@@ -0,0 +1,88 @@
+"""
+Entry point for the MRI Sequence Interpreter GUI.
+
+Run from the repo root:
+    python -m seq_interp.gui_app
+or:
+    python seq_interp/gui_app.py
+
+Optionally pass a .seq file as the first CLI argument to pre-load it:
+    python seq_interp/gui_app.py data/input/test1_full.seq
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+# Ensure the repo root (parent of seq_interp/) is importable regardless of
+# how the script is invoked.
+_here = os.path.dirname(os.path.abspath(__file__))
+_repo_root = os.path.dirname(_here)
+if _repo_root not in sys.path:
+    sys.path.insert(0, _repo_root)
+
+import argparse
+
+from PySide6.QtWidgets import QApplication
+
+from seq_interp.src.gui.main_window import MainWindow
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="MRI Sequence Interpreter — interactive GUI"
+    )
+    parser.add_argument(
+        "seq_file", nargs="?", default=None,
+        help="Optional .seq file to load on startup",
+    )
+    parser.add_argument(
+        "--hw-config", default=None,
+        help="Optional hardware config JSON to load on startup",
+    )
+    parser.add_argument(
+        "--output-dir", default=None,
+        help="Optional output directory",
+    )
+    args = parser.parse_args()
+
+    app = QApplication(sys.argv)
+    app.setApplicationName("MRI Sequence Interpreter")
+    app.setOrganizationName("LF-MRI")
+    # Qt6 / PySide6: high-DPI is enabled automatically — AA_UseHighDpiPixmaps
+    # is deprecated and emits a DeprecationWarning; omit it.
+
+    win = MainWindow()
+
+    # Centre window on primary screen (MainWindow.__init__ sizes it already,
+    # but doesn't centre)
+    screen = app.primaryScreen()
+    if screen is not None:
+        ag = screen.availableGeometry()
+        win.move(
+            ag.x() + (ag.width()  - win.width())  // 2,
+            ag.y() + (ag.height() - win.height()) // 2,
+        )
+
+    # Pre-load files from CLI arguments if given
+    if args.hw_config and os.path.isfile(args.hw_config):
+        win._hw_config_path = os.path.abspath(args.hw_config)
+        win._log(f"HW config pre-loaded: {win._hw_config_path}")
+
+    if args.output_dir:
+        win._output_dir = os.path.abspath(args.output_dir)
+        win._log(f"Output dir: {win._output_dir}")
+
+    if args.seq_file and os.path.isfile(args.seq_file):
+        win._seq_path = os.path.abspath(args.seq_file)
+        name = os.path.basename(win._seq_path)
+        win._set_seq_state("selected", name)
+        win._btn_run.setEnabled(True)
+        win._log(f"Sequence pre-loaded: {win._seq_path}")
+
+    win.show()
+    sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+    main()

+ 13 - 0
services/seq-interp/requirements.docker.txt

@@ -0,0 +1,13 @@
+# Docker-only requirements for seq-interp service.
+# Does NOT include PySide6 / pyqtgraph (GUI libs not needed in a headless container).
+
+matplotlib==3.10.8
+yattag==1.16.1
+scipy==1.17.1
+sigpy==0.1.27
+numpy==1.26.4
+numba==0.64.0
+uvicorn==0.41.0
+fastapi==0.135.1
+python-multipart==0.0.22
+httpx==0.28.1

+ 1 - 0
services/seq-interp/requirements.txt

@@ -0,0 +1 @@
+-r ../requirements.txt

+ 10 - 0
services/seq-interp/server.py

@@ -0,0 +1,10 @@
+import uvicorn
+from src.config import config
+
+if __name__ == "__main__":
+    uvicorn.run(
+        "api:app",
+        host=config.get("server_host", "0.0.0.0"),
+        port=config.get("server_port", 7475),
+        reload=True
+    )

+ 0 - 0
services/seq-interp/src/__init__.py


+ 37 - 0
services/seq-interp/src/config.py

@@ -0,0 +1,37 @@
+import json
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parents[1]
+SERVER_CONFIG_PATH = str(BASE_DIR / "cfg" / "server_config.json")
+HARDWARE_CONFIG_PATH = str(BASE_DIR / "cfg" / "hw_config.json")
+
+
+class Config:
+    def __init__(self, server_config_path=SERVER_CONFIG_PATH, hw_config_path=HARDWARE_CONFIG_PATH):
+        self.server_config_path = server_config_path
+        self.hardware_config_path = hw_config_path
+        self._load_config()
+
+    def _load_config(self):
+        """Загружает конфигурацию из JSON-файлов"""
+        if not os.path.exists(self.server_config_path):
+            raise FileNotFoundError(f"Файл конфигурации сервера {self.server_config_path} не найден!")
+        if not os.path.exists(self.hardware_config_path):
+            raise FileNotFoundError(f"Файл конфигурации томографа {self.hardware_config_path} не найден!")
+
+        with open(self.server_config_path, "r") as f:
+            self.config = json.load(f)
+        with open(self.hardware_config_path, "r") as f:
+            self.hw_config = json.load(f)
+
+    def get(self, key, default=None):
+        """Получить значение из server-конфига с безопасным доступом"""
+        return self.config.get(key, default)
+
+    def get_hw(self, key, default=None):
+        """Получить значение из hw-конфига с безопасным доступом"""
+        return self.hw_config.get(key, default)
+
+
+config = Config()

+ 0 - 0
services/seq-interp/src/core/__init__.py


+ 27 - 0
services/seq-interp/src/core/sequence_generator.py

@@ -0,0 +1,27 @@
+from seq_interp.src.hardware.constraints import HardwareConstraints
+import numpy as np
+
+
+class SequenceGenerator:
+    def __init__(self, hw: HardwareConstraints):
+        self.hw = hw
+
+    def generate_gre(self, params: dict) -> dict:
+        """Генерация последовательности Gradient Echo."""
+        t_grad = max(self.hw.MIN_BLOCK_DURATION, params.get("t_grad", 10e-3))
+
+        # Заглушка для примера - должна быть заменена реальной генерацией
+        dummy_grad = np.linspace(-self.hw.GRAD_MAX, self.hw.GRAD_MAX, 128)
+
+        return {
+            "gradients": {
+                'gx': dummy_grad,
+                'gy': np.zeros_like(dummy_grad),
+                'gz': np.zeros_like(dummy_grad),
+                't_gx': np.linspace(0, t_grad, 128),
+                't_gy': np.array([0, t_grad]),
+                't_gz': np.array([0])
+            },
+            "rf": np.zeros(128, dtype=np.complex64),
+            "adc": np.zeros(64, dtype=np.float32)
+        }

+ 76 - 0
services/seq-interp/src/core/synchronizer.py

@@ -0,0 +1,76 @@
+from seq_interp.src.hardware.constraints import HardwareConstraints
+
+
+class Synchronizer:
+    def __init__(self, hw: HardwareConstraints):
+        self.hw = hw
+
+    def process(self, sync_sequence):
+        """
+        Повторяет логику synchronization(...) из test1_full/srv_interp.py
+        и возвращает все массивы, нужные для экспорта XML и PicoScope.
+        """
+        synchro_block_timer = self.hw.MIN_BLOCK_DURATION
+        tr_delay = self.hw.TR_DELAY
+        rf_delay = self.hw.RF_DELAY
+        start_delay = max(self.hw.START_DELAY, self.hw.RF_DELAY)
+
+        min_block_time = 800e-9
+
+        if tr_delay < synchro_block_timer:
+            tr_delay = synchro_block_timer
+        if rf_delay < synchro_block_timer:
+            rf_delay = synchro_block_timer
+
+        number_of_blocks = len(sync_sequence.block_events)
+        gate_adc = [0]
+        gate_rf = [0]
+        gate_tr_switch = [1]
+        blocks_duration = [start_delay]
+
+        added_blocks = 0
+        for block_counter in range(number_of_blocks):
+            is_not_adc_block = True
+
+            if sync_sequence.block_events[block_counter + 1][5]:
+                is_not_adc_block = False
+
+                gate_adc.append(0)
+                gate_rf.append(gate_rf[-1])
+                blocks_duration[-1] -= tr_delay
+                blocks_duration.append(tr_delay)
+                gate_tr_switch.append(0)
+                added_blocks += 1
+
+                gate_adc.append(1)
+                gate_tr_switch.append(0)
+            else:
+                gate_tr_switch.append(1)
+                gate_adc.append(0)
+
+            if sync_sequence.block_events[block_counter + 1][1] and is_not_adc_block:
+                gate_rf.append(1)
+                gate_adc.append(gate_adc[-1])
+                blocks_duration[-1] -= rf_delay
+                blocks_duration.append(rf_delay)
+                gate_tr_switch.append(gate_tr_switch[-1])
+                added_blocks += 1
+
+                gate_rf.append(1)
+            else:
+                gate_rf.append(0)
+
+            current_block_dur = sync_sequence.block_durations[block_counter + 1]
+            blocks_duration.append(current_block_dur)
+
+        number_of_blocks += added_blocks
+
+        return {
+            "number_of_blocks": number_of_blocks,
+            "gate_adc": gate_adc,
+            "gate_rf": gate_rf,
+            "gate_tr_switch": gate_tr_switch,
+            "blocks_duration": blocks_duration,
+            "synchro_block_timer": synchro_block_timer,
+            "min_block_time": min_block_time,
+        }

+ 29 - 0
services/seq-interp/src/core/waveform_processor.py

@@ -0,0 +1,29 @@
+import numpy as np
+from numba import njit
+
+class WaveformProcessor:
+    """
+    Обработка форм сигналов (RF, градиенты, ADC) с использованием Numba для ускорения вычислений.
+    """
+    def __init__(self, hw):
+        self.hw = hw
+
+    @staticmethod
+    @njit(fastmath=True)
+    def process_rf_numba(rf_signal: np.ndarray, max_ampl: int) -> np.ndarray:
+        """Numba-оптимизированная обработка RF-сигнала: масштабирование и квантование под диапазон int8."""
+        # Умножение на максимальную амплитуду и округление до ближайшего целого
+        return np.round(rf_signal * max_ampl).astype(np.int8)
+
+    @staticmethod
+    @njit(fastmath=True)
+    def process_gradient_numba(grad_signal: np.ndarray, max_val: int) -> np.ndarray:
+        """Numba-оптимизированная обработка градиентного сигнала: масштабирование под диапазон int16."""
+        return np.round(grad_signal * max_val).astype(np.int16)
+
+    def preprocess_adc(self, adc_signal: np.ndarray) -> np.ndarray:
+        """
+        Подготовка данных АЦП для спектрометра:
+        приведение к типу float32 и обеспечение смежности в памяти.
+        """
+        return np.ascontiguousarray(adc_signal, dtype=np.float32)

+ 262 - 0
services/seq-interp/src/fid_gui/gui_RF_adj(FID).py

@@ -0,0 +1,262 @@
+# -*- coding: utf-8 -*-
+import os
+import sys
+
+# # PyInstaller-compatible way to find bundled resources
+# if hasattr(sys, '_MEIPASS'):
+#     sys.path.insert(0, os.path.join(sys._MEIPASS))
+# else:
+#     sys.path.insert(0, os.path.dirname(__file__))
+#
+
+import tkinter as tk
+from tkinter import ttk
+from math import ceil
+from seqgen_FID import seqgen_FID
+from datetime import datetime
+import numpy as np
+from types import SimpleNamespace
+import json
+from yattag import Doc, indent
+from matplotlib import pyplot as plt
+# from srv_interp import *
+from LF_scanner import pypulseq as pp
+
+
+
+
+def set_limits():
+    scale_rf = 1
+    G_amp_max = 5  # mT/m.   Максимальный градиент
+    G_slew_max = 45  # T/m/s.  Максимальная скорость нарастания
+    rf_raster_time = 1e-6  # s.      Растр РЧ импульса
+    grad_raster_time = 1e-6  # s.      Растр градиентов
+    rf_dead_time = 0e-6
+    adc_dead_time = 0e-6
+
+    # Задание общих аппаратных характкристик
+    gamma = 42.576e6  # Hz/T    Гиромагнитное отношение водорода
+    G_amp_max = G_amp_max * 1e-3 * gamma  # Hz/m.   Максимальный градиент
+    G_slew_max = G_slew_max * gamma  # Hz/m/s. Максимальная скорость нарастания
+    tau_max = G_amp_max / G_slew_max  # s.      Максимальное время нарастания градиента с учетом макс скорости нарастания
+    tau_max = np.ceil(tau_max / grad_raster_time) * grad_raster_time
+
+    # Чтение заданных в интерфэйс значений 
+    BW_per_point = float(M0_dj.textBox1.get())
+    N_point = float(M0_dj.textBox2.get())
+    TR = float(M0_dj.textBox5.get()) * 1e-3
+    NA = float(M0_dj.textBox6.get())
+    scale_rf = float(M0_dj.textBox11.get())
+    freq_offset = float(M0_dj.textBox7.get())
+    t_ex = float(M0_dj.textBox8.get()) * 1e-3
+    rf_ringdown_time = float(M0_dj.textBox9.get()) * 1e-3
+    is_sinc_pulse = False
+    if M0_dj.radio10.get() == 2:
+        is_sinc_pulse = True
+
+    # Длительность кратная растру градиента 10 мкс
+
+    t_BW_product_ex = 2.18  # -  Time bandwidth product
+    flip_angle = 90
+    apodization = 0.3
+    BW_ex_pulse = t_BW_product_ex / t_ex  # Hz. BW импульса
+
+    BW_full = BW_per_point * N_point
+    M0_dj.label4_1.configure(text=str(ceil(BW_full * 1000) / 1000))
+
+    global param
+    param = SimpleNamespace()
+    param.scale_rf = scale_rf
+    param.G_amp_max = G_amp_max
+    param.G_slew_max = G_slew_max
+    param.gamma = gamma
+    param.grad_raster_time = grad_raster_time
+    param.rf_raster_time = rf_raster_time
+
+    param.t_ex = t_ex
+    param.apodization = apodization
+    param.t_BW_product_ex = t_BW_product_ex
+    param.flip_angle = flip_angle
+    param.rf_ringdown_time = rf_ringdown_time
+    param.rf_dead_time = rf_dead_time
+    param.adc_dead_time = adc_dead_time
+    param.freq_offset = freq_offset
+
+    param.N_point = N_point
+    param.BW_per_point = BW_per_point
+    param.BW_full = BW_full
+    param.dG = tau_max
+    param.TR = TR
+    param.average = NA
+    param.is_sinc_pulse = is_sinc_pulse
+
+    output_sequence = seqgen_FID(param)
+    output_sequence.plot(win, time_range=(0, np.inf), plot_now=True, tk_plot=True, time_disp="ms")
+
+
+def save_param():
+    output_filename = str(M0_dj.textBox17.get())
+    output_sequence = seqgen_FID(param)
+
+    output_sequence.write("seq/" + output_filename)
+
+    file = open("seq/" + output_filename + ".json", 'w')
+    json.dump(param.__dict__, file, indent=4)
+    file.close()
+
+
+def interp_to_scanner():
+    print("interp")
+    CONST_HACK_RF_DELAY = 17 * 2 * 2
+    output_filename = str(M0_dj.textBox17.get())
+    SEQ_INPUT, SEQ_DICT = seq_file_input(seq_file_name="seq/" + output_filename + ".seq")
+    # SEQ_INPUT, SEQ_DICT = seq_file_input(seq_file_name='sequences/test1_full.seq')
+    # SEQ_INPUT, SEQ_DICT = seq_file_input(seq_file_name='sequences/turbo_FLASH_060924_0444.seq')
+
+    params_path = 'seq/'
+    params_filename = output_filename
+    # params_filename = "test1_full"
+    # params_filename = "turbo_FLASH_060924_0444"
+
+    file = open(params_path + params_filename + ".json", 'r')
+    SEQ_PARAM = json.load(file)
+
+    file.close()
+
+    # artificial delays due to construction of the MRI
+    # искусственные задержки из-за тех. особенностей МРТ
+    # RF_dtime = 10 * 1e-6
+    # TR_dtime = 10 * 1e-6
+
+    time_info = SEQ_INPUT.duration()
+    blocks_number = time_info[1]
+    time_dur = time_info[0]
+
+    # output interpretation. all formats of files defined in method
+    # интерпретация выхода. Все форматы файлов определены в методе
+    output_seq(SEQ_DICT, SEQ_PARAM, SEQ_INPUT, path="interp/")
+
+    # defining constants of the sequence
+    # определение констант последовательности
+    local_definitions = SEQ_INPUT.definitions
+    ADC_raster = local_definitions['AdcRasterTime']
+    RF_raster = local_definitions['RadiofrequencyRasterTime']
+
+    synchronization(SEQ_INPUT, plot_sequence=False, path="interp/")
+
+
+### Default values ###
+
+BW_per_point = 100
+N_point = 512
+TR = 200
+NA = 1
+freq_offset = 0
+t_ex = 1
+rf_ringdown_time = 100e-3
+scale_rf = 1
+
+win = tk.Tk()
+win.title('FID')
+win.geometry("520x420+100+100")
+# win.resizable(False,False)
+
+# создаем набор вкладок
+notebook = ttk.Notebook()
+notebook.pack(expand=True, fill='both')
+
+Main = tk.Frame(notebook)
+Main.pack(fill='both', expand=True)
+notebook.add(Main, text="FID")
+
+M0_dj = SimpleNamespace()
+
+# Slice thickness, m
+tk.Label(Main, text='BW_per_point, Hz').grid(row=1, column=0, sticky="E")
+M0_dj.textBox1 = tk.Entry(Main, width=6)
+M0_dj.textBox1.insert(0, BW_per_point)
+M0_dj.textBox1.grid(row=1, column=1)
+M0_dj.label1_1 = tk.Label(Main)
+M0_dj.label1_1.grid(row=1, column=2)
+
+tk.Label(Main, text='N_point').grid(row=2, column=0, sticky="E")
+M0_dj.textBox2 = tk.Entry(Main, width=6)
+M0_dj.textBox2.insert(0, N_point)
+M0_dj.textBox2.grid(row=2, column=1)
+M0_dj.label2_1 = tk.Label(Main)
+M0_dj.label2_1.grid(row=2, column=2)
+
+M0_dj.label4 = tk.Label(Main, text='BW_full, Hz')
+M0_dj.label4.grid(row=3, column=0, sticky="E")
+M0_dj.label4_1 = tk.Label(Main)
+M0_dj.label4_1.grid(row=3, column=2)
+
+M0_dj.label5 = tk.Label(Main, text='TR, ms')
+M0_dj.label5.grid(row=4, column=0, sticky="E")
+M0_dj.textBox5 = tk.Entry(Main, width=6)
+M0_dj.textBox5.insert(0, TR)
+M0_dj.textBox5.grid(row=4, column=1)
+M0_dj.label5_1 = tk.Label(Main)
+M0_dj.label5_1.grid(row=4, column=2)
+
+M0_dj.label6 = tk.Label(Main, text='NA')
+M0_dj.label6.grid(row=5, column=0, sticky="E")
+M0_dj.textBox6 = tk.Entry(Main, width=6)
+M0_dj.textBox6.insert(0, NA)
+M0_dj.textBox6.grid(row=5, column=1)
+M0_dj.label6_1 = tk.Label(Main)
+M0_dj.label6_1.grid(row=5, column=2)
+
+M0_dj.label7 = tk.Label(Main, text='Freq_offset')
+M0_dj.label7.grid(row=6, column=0, sticky="E")
+M0_dj.textBox7 = tk.Entry(Main, width=6)
+M0_dj.textBox7.insert(0, freq_offset)
+M0_dj.textBox7.grid(row=6, column=1)
+
+M0_dj.label8 = tk.Label(Main, text='RF_pulse_dur, ms')
+M0_dj.label8.grid(row=7, column=0, sticky="E")
+M0_dj.textBox8 = tk.Entry(Main, width=6)
+M0_dj.textBox8.insert(0, t_ex)
+M0_dj.textBox8.grid(row=7, column=1)
+
+M0_dj.label9 = tk.Label(Main, text='RF-ADC delay, ms')
+M0_dj.label9.grid(row=8, column=0, sticky="E")
+M0_dj.textBox9 = tk.Entry(Main, width=6)
+M0_dj.textBox9.insert(0, rf_ringdown_time)
+M0_dj.textBox9.grid(row=8, column=1)
+
+M0_dj.label11 = tk.Label(Main, text='RF scale')
+M0_dj.label11.grid(row=10, column=0, sticky="E")
+M0_dj.textBox11 = tk.Entry(Main, width=6)
+M0_dj.textBox11.insert(0, scale_rf)
+M0_dj.textBox11.grid(row=10, column=1)
+
+M0_dj.radio10 = tk.IntVar()
+M0_dj.radio10.set(1)
+
+M0_dj.label10 = tk.Label(Main, text="Pulse_type:")
+M0_dj.label10.grid(row=9, column=0, sticky="E")
+M0_dj.R1_10 = tk.Radiobutton(Main, text="block", variable=M0_dj.radio10, value=1)
+M0_dj.R1_10.grid(row=9, column=1)
+M0_dj.R2_10 = tk.Radiobutton(Main, text="sinc", variable=M0_dj.radio10, value=2)
+M0_dj.R2_10.grid(row=9, column=2)
+
+# set_limits()
+
+M0_dj.btn1 = tk.Button(Main, text='Set', command=set_limits, width=10)
+M0_dj.btn1.grid(row=1, column=5, sticky="W")
+
+M0_dj.btn1 = tk.Button(Main, text='Save', command=save_param, width=10)
+M0_dj.btn1.grid(row=3, column=5, sticky="W")
+
+M0_dj.btn1 = tk.Button(Main, text='Load to scanner', command=interp_to_scanner, width=20)
+M0_dj.btn1.grid(row=5, column=5, sticky="W")
+
+# filename
+M0_dj.label17 = tk.Label(Main, text='Output file name', width=13)
+M0_dj.label17.grid(row=6, column=5, sticky="W")
+M0_dj.textBox17 = tk.Entry(Main, width=25)
+M0_dj.textBox17.insert(0, "FID_" + datetime.now().strftime("%d%m%y_%H%M"))
+M0_dj.textBox17.grid(row=7, column=5, columnspan=3, sticky="W")
+
+win.mainloop()

+ 229 - 0
services/seq-interp/src/fid_gui/gui_RF_adj(FID)_console.py

@@ -0,0 +1,229 @@
+# -*- coding: utf-8 -*-
+
+import tkinter as tk
+from tkinter import ttk
+from math import ceil 
+import numpy as np
+from types import SimpleNamespace
+from seqgen_FID import seqgen_FID
+from datetime import datetime
+import json
+from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
+from matplotlib.figure import Figure
+
+
+def set_limits():
+    
+    
+    G_amp_max = 5                    # mT/m.   Максимальный градиент
+    G_slew_max = 45                  # T/m/s.  Максимальная скорость нарастания
+    rf_raster_time = 1e-6            # s.      Растр РЧ импульса
+    grad_raster_time = 1e-6           # s.      Растр градиентов
+    rf_dead_time=0e-6
+    adc_dead_time=0e-6
+    
+    
+    
+    # Задание общих аппаратных характкристик
+    gamma = 42.576e6                   # Hz/T    Гиромагнитное отношение водорода
+    G_amp_max = G_amp_max*1e-3*gamma   # Hz/m.   Максимальный градиент
+    G_slew_max = G_slew_max*gamma      # Hz/m/s. Максимальная скорость нарастания
+    tau_max = G_amp_max/G_slew_max     # s.      Максимальное время нарастания градиента с учетом макс скорости нарастания
+    tau_max = np.ceil(tau_max/ grad_raster_time)*grad_raster_time
+    
+
+    
+    # Чтение заданных в интерфэйс значений 
+    BW_per_point = float(M0_dj.textBox1.get())
+    N_point = float(M0_dj.textBox2.get())
+    TR = float(M0_dj.textBox5.get())*1e-3
+    NA = float(M0_dj.textBox6.get())
+    freq_offset = float(M0_dj.textBox7.get())
+    t_ex = float(M0_dj.textBox8.get())*1e-3
+    rf_ringdown_time=float(M0_dj.textBox9.get())*1e-3
+    is_sinc_pulse = False
+    if M0_dj.radio10.get() == 2:
+        is_sinc_pulse = True
+
+    # Длительность кратная растру градиента 10 мкс
+    
+    t_BW_product_ex = 2.18               # -  Time bandwidth product 
+    flip_angle = 90
+    apodization = 0.3                    
+    BW_ex_pulse = t_BW_product_ex/t_ex   # Hz. BW импульса
+
+    
+    BW_full = BW_per_point*N_point
+    M0_dj.label4_1.configure(text = str(ceil(BW_full*1000)/1000))
+
+    global param
+    param = SimpleNamespace()
+    param.G_amp_max = G_amp_max
+    param.G_slew_max = G_slew_max
+    param.gamma = gamma
+    param.grad_raster_time = grad_raster_time
+    param.rf_raster_time = rf_raster_time
+    
+    param.t_ex = t_ex
+    param.apodization = apodization
+    param.t_BW_product_ex = t_BW_product_ex
+    param.flip_angle = flip_angle
+    param.rf_ringdown_time = rf_ringdown_time
+    param.rf_dead_time = rf_dead_time
+    param.adc_dead_time = adc_dead_time
+    param.freq_offset = freq_offset
+    
+
+    param.N_point = N_point   
+    param.BW_per_point = BW_per_point
+    param.BW_full = BW_full
+    param.dG = tau_max
+    param.TR = TR
+    param.average = NA
+    param.is_sinc_pulse = is_sinc_pulse
+
+    output_sequence = seqgen_FID(param)
+    output_sequence.plot(win, time_range=(0, np.inf), plot_now=True, tk_plot=True, time_disp="ms")
+
+def save_param():
+
+    output_filename = str(M0_dj.textBox17.get())
+    output_sequence = seqgen_FID(param)
+
+    output_sequence.write("seq/"+output_filename)
+    
+    file = open("seq/"+output_filename + ".json", 'w')
+    json.dump(param.__dict__, file, indent = 4)
+    file.close()    
+
+
+def interp_to_scanner():
+    file = open("interp/rf_arr.json", 'w')
+    json.dump(param.__dict__, file, indent=4)
+    file.close()
+    print("interp")
+
+### Default values ###
+  
+BW_per_point = 100
+N_point = 512     
+TR = 200 
+NA = 1
+freq_offset = 0
+t_ex = 1
+rf_ringdown_time = 100e-3
+
+win = tk.Tk()
+win.title('FID')
+win.geometry("520x420+100+100")
+# win.resizable(False,False)
+
+# создаем набор вкладок
+notebook = ttk.Notebook()
+notebook.pack(expand=True, fill='both')
+
+Main = tk.Frame(notebook)
+Main.pack(fill='both', expand=True)
+notebook.add(Main, text="FID")
+
+M0_dj = SimpleNamespace()
+
+ 
+
+# Slice thickness, m  
+tk.Label(Main, text = 'BW_per_point, Hz').grid(row=1, column=0,sticky = "E")
+M0_dj.textBox1 = tk.Entry(Main, width = 6)
+M0_dj.textBox1.insert(0, BW_per_point)
+M0_dj.textBox1.grid(row=1, column=1)
+M0_dj.label1_1 = tk.Label(Main)
+M0_dj.label1_1.grid(row=1, column=2)
+
+ 
+tk.Label(Main, text = 'N_point').grid(row=2, column=0,sticky = "E")
+M0_dj.textBox2 = tk.Entry(Main, width = 6)
+M0_dj.textBox2.insert(0, N_point)
+M0_dj.textBox2.grid(row=2, column=1)
+M0_dj.label2_1 = tk.Label(Main)
+M0_dj.label2_1.grid(row=2, column=2)
+
+ 
+M0_dj.label4 = tk.Label(Main, text = 'BW_full, Hz')
+M0_dj.label4.grid(row=3, column=0,sticky = "E")
+M0_dj.label4_1 = tk.Label(Main)
+M0_dj.label4_1.grid(row=3, column=2)
+
+
+M0_dj.label5 = tk.Label(Main, text = 'TR, ms')
+M0_dj.label5.grid(row=4, column=0,sticky = "E")
+M0_dj.textBox5 = tk.Entry(Main, width = 6)
+M0_dj.textBox5.insert(0, TR)
+M0_dj.textBox5.grid(row=4, column=1)
+M0_dj.label5_1 = tk.Label(Main)
+M0_dj.label5_1.grid(row=4, column=2)
+
+M0_dj.label6 = tk.Label(Main, text = 'NA')
+M0_dj.label6.grid(row=5, column=0,sticky = "E")
+M0_dj.textBox6 = tk.Entry(Main, width = 6)
+M0_dj.textBox6.insert(0, NA)
+M0_dj.textBox6.grid(row=5, column=1)
+M0_dj.label6_1 = tk.Label(Main)
+M0_dj.label6_1.grid(row=5, column=2)
+
+M0_dj.label7 = tk.Label(Main, text = 'Freq_offset')
+M0_dj.label7.grid(row=6, column=0,sticky = "E")
+M0_dj.textBox7 = tk.Entry(Main, width = 6)
+M0_dj.textBox7.insert(0, freq_offset)
+M0_dj.textBox7.grid(row=6, column=1)
+
+
+M0_dj.label8 = tk.Label(Main, text = 'RF_pulse_dur, ms')
+M0_dj.label8.grid(row=7, column=0,sticky = "E")
+M0_dj.textBox8 = tk.Entry(Main, width = 6)
+M0_dj.textBox8.insert(0, t_ex)
+M0_dj.textBox8.grid(row=7, column=1)
+
+
+M0_dj.label9 = tk.Label(Main, text = 'RF-ADC delay, ms')
+M0_dj.label9.grid(row=8, column=0,sticky = "E")
+M0_dj.textBox9 = tk.Entry(Main, width = 6)
+M0_dj.textBox9.insert(0, rf_ringdown_time)
+M0_dj.textBox9.grid(row=8, column=1)
+
+
+M0_dj.radio10 = tk.IntVar() 
+M0_dj.radio10.set(1)
+
+M0_dj.label10 = tk.Label(Main, text = "Pulse_type:")
+M0_dj.label10.grid(row=9, column=0, sticky = "E")
+M0_dj.R1_10 = tk.Radiobutton(Main, text="block", variable=M0_dj.radio10, value=1) 
+M0_dj.R1_10.grid(row=9, column=1 ) 
+M0_dj.R2_10 = tk.Radiobutton(Main, text="sinc", variable=M0_dj.radio10, value=2) 
+M0_dj.R2_10.grid(row=9, column=2 )
+
+
+# set_limits()
+
+M0_dj.btn1 = tk.Button(Main, text = 'Set', command = set_limits, width = 10)
+M0_dj.btn1.grid(row=1, column=5,sticky = "W")
+
+
+
+M0_dj.btn1 = tk.Button(Main, text = 'Save', command = save_param, width = 10)
+M0_dj.btn1.grid(row=3, column=5,sticky = "W")
+
+M0_dj.btn1 = tk.Button(Main, text = 'Load to scanner', command = interp_to_scanner, width = 20)
+M0_dj.btn1.grid(row=5, column=5,sticky = "W")
+
+# filename
+M0_dj.label17= tk.Label(Main, text = 'Output file name',width = 13)
+M0_dj.label17.grid(row=6, column=5,sticky = "W")
+M0_dj.textBox17 = tk.Entry(Main,width = 25)
+M0_dj.textBox17.insert(0, "FID_" + datetime.now().strftime("%d%m%y_%H%M"))
+M0_dj.textBox17.grid(row=7, column=5, columnspan=3,sticky = "W")
+
+
+
+win.mainloop()
+
+
+

+ 56 - 0
services/seq-interp/src/fid_gui/seqgen_FID.py

@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+
+# import LF_scanner.pypulseq as pp
+import numpy as np
+import pypulseq as pp
+
+
+def seqgen_FID(param):
+    # Read scanner parameters from the params structure
+    scanner_parameters = pp.Opts(max_grad=param.G_amp_max, grad_unit='Hz/m', \
+                                 max_slew=param.G_slew_max, slew_unit='Hz/m/s', \
+                                 grad_raster_time=param.grad_raster_time, \
+                                 rf_raster_time=param.rf_raster_time, \
+                                 adc_raster_time=1 / (param.BW_per_point * param.N_point), \
+                                 block_duration_raster=max(param.grad_raster_time, param.rf_raster_time), \
+                                 rf_dead_time=param.rf_dead_time, \
+                                 rf_ringdown_time=param.rf_ringdown_time, \
+                                 adc_dead_time=param.adc_dead_time)
+
+    # Generate the RF pulse
+    exc_pulse_offsets = 0
+
+    if param.is_sinc_pulse:
+        exc_pulse = pp.make_sinc_pulse(flip_angle=param.flip_angle / 180 * np.pi, \
+                                       duration=np.double(param.t_ex), \
+                                       apodization=param.apodization, \
+                                       freq_offset=param.freq_offset, \
+                                       phase_offset=0, \
+                                       time_bw_product=param.t_BW_product_ex, \
+                                       system=scanner_parameters)
+    else:
+        exc_pulse = pp.make_block_pulse(flip_angle=param.flip_angle / 180 * np.pi, system=scanner_parameters,
+                                        duration=np.double(param.t_ex))
+
+    # Generate the ADC event
+    adc_module = pp.make_adc(num_samples=param.N_point, duration=1 / param.BW_per_point, system=scanner_parameters)
+    adc_module.phase_offset = 0
+
+    # Calculate the TR fillers
+    TR = param.TR
+    tr_delay = TR - pp.calc_duration(exc_pulse) - pp.calc_duration(adc_module)
+    tr_delay = np.ceil(tr_delay / param.grad_raster_time) * param.grad_raster_time
+    tr_delay = pp.make_delay(tr_delay)
+
+    seq = pp.Sequence(system=scanner_parameters)
+
+    for n in range(int(param.average)-1):
+        # Add RF and gradient block
+        seq.add_block(exc_pulse)
+        seq.add_block(adc_module)
+        seq.add_block(tr_delay)
+    seq.add_block(exc_pulse)
+    seq.add_block(adc_module)
+
+    print(pp.check_timing.check_timing(seq))
+    return seq

+ 655 - 0
services/seq-interp/src/fid_gui/srv_interp.py

@@ -0,0 +1,655 @@
+# -*- coding: utf-8 -*-
+"""
+Created on 05/09/2024
+
+@author: spacexer
+"""
+from LF_scanner import pypulseq as pp
+import numpy as np
+from types import SimpleNamespace
+import json
+from yattag import Doc, indent
+from matplotlib import pyplot as plt
+
+from utilities.param_constants import param_rf_GRE
+
+'''
+integration from srv_seq_gen
+'''
+
+rf = param_rf_GRE()
+
+AU = 128
+NA = 128
+t_grad = 10e-3
+delay_2 = 20e-3
+grad1_pol = 1
+
+
+def set_limits():
+    # Задание общих аппаратных характкристик
+    gamma = 42.576e6  # Hz/T    Гиромагнитное отношение водорода
+    G_amp_max_mT_m = 9  # mT/m.   Максимальный градиент
+    G_amp_max = G_amp_max_mT_m * 1e-3 * gamma  # Hz/m.   Максимальный градиент
+    G_slew_max_T_m_s = 30  # T/m/s.  Максимальная скорость нарастания
+    G_slew_max = G_slew_max_T_m_s * gamma  # Hz/m/s. Максимальная скорость нарастания
+    rf_raster_time = 5e-8  # s.      Растр РЧ импульса
+    grad_raster_time = 10e-6  # s.      Растр градиентов
+    tau_max = G_amp_max / G_slew_max  # s.      Максимальное время нарастания градиента с учетом макс скорости нарастания
+    tau_max = np.ceil(tau_max / grad_raster_time) * grad_raster_time
+
+    N_grad = 128
+    grad_step = G_amp_max / N_grad
+    grad_amp = grad_step * AU
+
+    AU_min, AU_max = 0, 128
+    NA_min, NA_max = 1, "-"
+    t_grad_min, t_grad_max = 1e-3, 50e-3
+    delay_1_min, delay_1_max = 1e-3, 50e-3
+    delay_2_min, delay_2_max = 1e-3, 50e-3
+
+    global param
+    param = SimpleNamespace()
+    param.G_amp_max = G_amp_max
+    param.G_slew_max = G_slew_max
+    param.gamma = gamma
+    param.grad_raster_time = grad_raster_time
+    param.rf_raster_time = rf_raster_time
+
+    param.grad_amp = grad_amp
+    param.NA = NA
+    param.t_grad = t_grad
+    param.grad1_pol = grad1_pol
+    param.filename = "test_seq.png"
+    param.FA = 90
+    '''
+    only for srv_interp
+    '''
+    return param
+
+
+def save_param(path='sequences/'):
+    output_filename = "test1_full"
+    output_sequence = seqgen_GRAD_TEST_v2(param, output_filename)
+    output_sequence.plot(save=True, plot_now=False)
+    output_sequence.write(path + output_filename)
+    file = open(path + output_filename + ".json", 'w')
+    json.dump(param.__dict__, file, indent=4)
+    file.close()
+    '''
+    only for srv_interp
+    '''
+    return output_sequence
+
+
+def seqgen_GRAD_TEST_v2(param, filename):
+    # Read scanner parameters from the params structure
+    scanner_parameters = pp.Opts(max_grad=param.G_amp_max, grad_unit='Hz/m',
+                                 max_slew=param.G_slew_max, slew_unit='Hz/m/s',
+                                 grad_raster_time=param.grad_raster_time,
+                                 rf_raster_time=param.rf_raster_time,
+                                 block_duration_raster=max(param.grad_raster_time, param.rf_raster_time))
+
+    # For all modules
+    gradient_ramp_time = param.grad_raster_time * np.ceil(
+        (scanner_parameters.max_grad / scanner_parameters.max_slew) / param.grad_raster_time)
+
+    # Calculate slice-selection gradient
+
+    t_grad = 1e-3
+    delta_time = 0.5e-3
+    grad1_pol = 1
+    delay1 = 1e-3
+    N_grad = 128
+    grad_step = param.G_amp_max / N_grad
+    grad_amp = grad_step
+    scanner_parameters.max_grad = scanner_parameters.max_grad * 1.001
+    seq = pp.Sequence(system=scanner_parameters)
+    g_raster = param.grad_raster_time
+
+    for i in range(N_grad):
+        if i == 7:
+            break
+        if i <= 100:
+            gradient_1 = pp.make_trapezoid(channel='x',
+                                           flat_time=np.double(t_grad),
+                                           amplitude=grad_amp * (N_grad - i - 1) * -grad1_pol,
+                                           system=scanner_parameters)
+
+            delay1 = param.grad_raster_time * np.floor(delay1 / g_raster)
+
+            # Generate the TE delay
+            delay1_grad = pp.make_delay(delay1)
+            phase_shift = 0
+            t_grad += delta_time
+        else:
+            delay1 += delta_time
+            gradient_1 = pp.make_trapezoid(channel='x',
+                                           flat_time=np.double(t_grad),
+                                           amplitude=grad_amp * (i + 1) * grad1_pol,
+                                           system=scanner_parameters)
+
+            delay1 = param.grad_raster_time * np.floor(delay1 / g_raster)
+
+            # Generate the TE delay
+            delay1_grad = pp.make_delay(delay1)
+            phase_shift = 0
+        seq.add_block(gradient_1)
+        seq.add_block(delay1_grad)
+
+        exc_pulse = pp.make_sinc_pulse(flip_angle=0,
+                                       duration=np.double(rf.t_ex),
+                                       # freq_offset = curr_offset,
+                                       phase_offset=0,
+                                       time_bw_product=3.8,
+                                       delay=gradient_ramp_time,
+                                       system=scanner_parameters)
+        seq.add_block(exc_pulse)
+        adc_module = pp.make_adc(num_samples=1, duration=1e-5, delay=gradient_ramp_time,
+                                 system=scanner_parameters)
+        seq.add_block(adc_module)
+    return seq
+
+
+''''''''''''''''''''''''
+
+
+def cumsum(a, b, c=None, d=None, e=None):
+    if e != None:
+        s1 = a + b
+        s2 = s1 + c
+        s3 = s2 + d
+        return (a, s1, s2, s3, s3 + e)
+    elif d != None:
+        s1 = a + b
+        s2 = s1 + c
+        return (a, s1, s2, s2 + d)
+    elif c != None:
+        s = a + b
+        return (a, s, s + c)
+    else:
+        return (a, a + b)
+
+
+def seq_file_input(seq_file_name="empty.seq"):
+    seq_input = pp.Sequence()
+    seq_input.read(file_path=seq_file_name)
+    seq_output_dict = seq_input.waveforms_export()
+    return seq_input, seq_output_dict
+
+
+def output_seq(dict, param, seq, path='test1/'):
+    """
+    The interpretation from pypulseq format of sequence to the files needed to analog part of MRI
+
+    :param dict: Dictionary of the impulse sequence pypulseq provided
+
+    :return: files in "grad_output/" directory of every type of amplitudes and time points
+
+    """
+    '''
+    Gradient
+    '''
+    grad_signal = [
+        np.zeros([1]),
+        np.zeros([1]),
+        np.zeros([1]),
+    ]
+    grad_time = [
+        np.zeros([1]),
+        np.zeros([1]),
+        np.zeros([1]),
+    ]
+    rf_signal = np.zeros([1])
+    rf_time = np.zeros([1])
+    rf_signal_v2 = np.empty([0])
+    rf_time_v2 = np.empty([0])
+    time_disp: str = "s"
+    valid_time_units = ["s", "ms", "us"]
+    t_factor_list = [1, 1e3, 1e6]
+    t_factor = t_factor_list[valid_time_units.index(time_disp)]
+    valid_grad_units = ["kHz/m", "mT/m"]
+    grad_disp: str = "kHz/m"
+    g_factor_list = [1e-3, 1e3 / seq.system.gamma]
+    g_factor = g_factor_list[valid_grad_units.index(grad_disp)]
+    t0 = 0
+    time_range = (0, np.inf)
+    current_sequence = seq
+    for block_counter in current_sequence.block_events:
+        # if block_counter>2:
+        #     break
+        block = current_sequence.get_block(block_counter)
+        is_valid = (time_range[0] <= t0 + seq.block_durations[block_counter]
+                    and t0 <= time_range[1])
+        grad_channels = ["gx", "gy", "gz"]
+        if is_valid:
+            for x in range(len(grad_channels)):  # Gradients
+                if getattr(block, grad_channels[x], None) is not None:
+                    grad = getattr(block, grad_channels[x])
+                    if grad.type == "grad":
+                        # We extend the shape by adding the first and the last points in an effort of making the
+                        # display a bit less confusing...
+                        time = grad.delay + np.array([0, *grad.tt, grad.shape_dur])
+                        waveform = g_factor * np.array(
+                            (grad.first, *grad.waveform, grad.last)
+                        )
+                    else:
+                        time = np.array(cumsum(
+                            0,
+                            grad.delay,
+                            grad.rise_time,
+                            grad.flat_time,
+                            grad.fall_time,
+                        ))
+                        waveform = (
+                                g_factor * grad.amplitude * np.array([0, 0, 1, 1, 0])
+                        )
+
+                    grad_time[x] = np.concatenate((grad_time[x], t_factor * (t0 + time)), axis=None)
+                    grad_signal[x] = np.concatenate((grad_signal[x], waveform), axis=None)
+            if getattr(block, "rf", None) is not None:  # RF
+                rf = block.rf
+                tc, ic = pp.calc_rf_center(rf)
+                time = rf.t
+                signal = rf.signal
+                if abs(signal[0]) != 0:
+                    signal = np.concatenate(([0], signal))
+                    time = np.concatenate(([time[0]], time))
+                    ic += 1
+                if abs(signal[-1]) != 0:
+                    signal = np.concatenate((signal, [0]))
+                    time = np.concatenate((time, [time[-1]]))
+                rf_signal = np.concatenate((rf_signal, np.abs(signal)), axis=None)
+                rf_time = np.concatenate((rf_time, t_factor * (t0 + time + rf.delay)), axis=None)
+            t0 += seq.block_durations[block_counter]
+    start_time = 0
+    end_time = grad_time[2][-1]
+    time_step = abs(dict["t_rf"][1] - dict["t_rf"][0])  # Растр (шаг) времени
+    flag_grad = False
+    # Создаём равномерную временную сетку
+    time_array = np.arange(start_time, end_time + time_step, time_step)
+    if start_time!=end_time:
+        flag_grad = True
+        "Found gradients in the sequence"
+        # Интерполируем значения амплитуды на этой сетке
+        amplitude_array = np.interp(time_array, grad_time[0], grad_signal[0])
+        plt.plot(time_array, amplitude_array, label="Gx")
+        amplitude_array = np.interp(time_array, grad_time[1], grad_signal[1])
+        plt.plot(time_array, amplitude_array, label="Gy")
+        amplitude_array = np.interp(time_array, grad_time[2], grad_signal[2])
+        plt.plot(time_array, amplitude_array, label="Gz")
+    plt.plot(rf_time, rf_signal)
+    # plt.plot(rf_time_v2, rf_signal_v2)
+    plt.legend()
+    plt.xlim((0, 0.02))
+    # plt.plot(dict["t_rf"][0:7000], np.abs(dict["rf"])[0:7000])
+    plt.show()
+    # np.save('for_walid/sequence.npy', array_list)
+    # np.savetxt(path + 'for_walid/grad_time.txt', time_array)
+    # np.savetxt(path + 'for_walid/grad_x.txt', grad_time[0])
+    # np.savetxt(path + 'for_walid/grad_y.txt', grad_time[1])
+    # np.savetxt(path + 'for_walid/grad_z.txt', grad_time[2])
+    # np.savetxt(path + 'for_walid/rf_time.txt', rf_time)
+    # np.savetxt(path + 'for_walid/rf_ampl.txt', rf_signal)
+
+    # np.savetxt(path + 'rf_time.txt', np.transpose(dict["t_rf"]))
+    # np.savetxt(path + 'rf_ampl.txt', np.transpose(dict["rf"]))
+    if flag_grad:
+        loc_t_gx = gradient_time_convertation(param, dict['t_gx'])
+        loc_t_gy = gradient_time_convertation(param, dict['t_gy'])
+        loc_t_gz = gradient_time_convertation(param, dict['t_gz'])
+        loc_gx = gradient_ampl_convertation(param, dict['gx'])
+        loc_gy = gradient_ampl_convertation(param, dict['gy'])
+        loc_gz = gradient_ampl_convertation(param, dict['gz'])
+        gx_out = duplicates_delete(np.transpose([loc_t_gx, loc_gx]))
+        gy_out = duplicates_delete(np.transpose([loc_t_gy, loc_gy]))
+        gz_out = duplicates_delete(np.transpose([loc_t_gz, loc_gz]))
+        np.savetxt(path + 'gx.txt', gx_out, fmt='%10.0f')
+        np.savetxt(path + 'gy.txt', gy_out, fmt='%10.0f')
+        np.savetxt(path + 'gz.txt', gz_out, fmt='%10.0f')
+    '''
+    Radio
+    '''
+    rf_raster_local = param['rf_raster_time']
+    #TODO: nulls at the beginning, control by rf_raster
+
+    if rf_raster_local == 5e-7:
+        empty_block_time_delay = 17.7e-6  # mks
+    elif rf_raster_local == 2.5e-7:
+        empty_block_time_delay = 3.6e-6  # mks
+    elif rf_raster_local == 5e-8:
+        empty_block_time_delay = 1.77e-6  # mks
+    else:
+        print("RF raster time is invalid, pulse sequence interpretation could be incorrect")
+        empty_block_time_delay = 0
+    rf_out = [0] * int(2 * (empty_block_time_delay // rf_raster_local))
+    rf_out += radio_ampl_convertation(dict["rf"], dict["t_rf"], rf_raster_local)
+    rf_out = ([round(x*param["scale_rf"]) for x in rf_out])
+    plt.plot(rf_out)
+    plt.title("Scaled rf signal")
+    plt.show()
+    file_rf = open(path + 'rf_' + str(rf_raster_local) + '_raster.bin', "wb")
+    for byte in rf_out:
+        file_rf.write(byte.to_bytes(1, byteorder='big', signed=1))
+    file_rf.close()
+    #
+    # '''
+    # for radiofreq tests
+    # '''
+    # np.savetxt(path + 'rf_time.txt', np.transpose(dict["t_rf"]))
+    # np.savetxt(path + 'rf_ampl.txt', np.transpose(dict["rf"]))
+    # plt.scatter(dict["t_rf"], np.real(dict["rf"]), label="real", s=1)
+    # plt.legend()
+    # plt.show()
+
+
+def radio_ampl_convertation(rf_ampl, t_rf, rf_raster_local):
+    #TODO: sampling resize to raster different with seqgen
+    out_rf_list = []
+    rf_ampl_raster = 127
+    rf_ampl_maximum = np.abs(max(rf_ampl))
+    proportional_cf_rf = rf_ampl_raster / rf_ampl_maximum
+    print(t_rf)
+    num_zeroes = 0
+    for rf_iter in range(len(rf_ampl) - 1):
+        if abs(t_rf[rf_iter] - t_rf[rf_iter + 1]) > 2 * rf_raster_local:
+            num_zeroes += int(np.abs((t_rf[rf_iter] - t_rf[rf_iter + 1]) / rf_raster_local))
+        else:
+            out_rf_list += [0] * num_zeroes
+            num_zeroes = 0
+            out_rf_list.append(round(rf_ampl[rf_iter].real * proportional_cf_rf))
+            out_rf_list.append(round(rf_ampl[rf_iter].imag * proportional_cf_rf))
+    return out_rf_list
+
+
+def duplicates_delete(loc_list):
+    new_list = [[0] * 2]
+    for i in range(len(loc_list)):
+        if loc_list[i][0] not in np.transpose(new_list)[0]:
+            new_list.append(loc_list[i])
+    return new_list
+
+
+def gradient_time_convertation(param_loc, time_sample):
+    g_raster_time = param_loc['grad_raster_time']
+    time_sample /= g_raster_time
+    return time_sample
+
+
+def gradient_ampl_convertation(param, gradient_herz):
+    """
+    Helper function that convert amplitudes to dimensionless format for machine
+    1 bit for sign, 15 bits of numbers
+
+    :param gradient_herz: 2D array of amplitude and time points in Hz/m
+
+    :return: gradient_dimless: 2D array of dimensionless points
+
+    """
+    # amplitude raster is 32768
+    # maximum grad = 10 mT/m
+
+    # artificial gap is 1 mT/m so 9 mT/m is now should be split in parts
+    amplitude_max = param['G_amp_max']
+    amplitude_raster = 32767
+    step_Hz_m = amplitude_max / amplitude_raster  # Hz/m step gradient
+    gradient_dimless = gradient_herz / step_Hz_m * 1000
+    # assert abs(any(gradient_dimless)) > 32768, 'Amplitude is higher than expected, check the rate number'
+    return gradient_dimless
+
+
+def adc_correction(blocks_number_loc, seq_input_loc):
+    """
+    Helper function that rise times for correction of ADC events
+    Вспомогательная функция получения времён для коррекции АЦП событий
+    :return:    rise_time: float, stores in pulseq, related to exact type of gradient events
+                    хранится в pulseq, связан с конкретным типом градиентного события
+                fall_time: float, same as rise_time
+                    аналогично rise_time
+    """
+    rise_time, fall_time = None, None
+    is_adc_inside = False
+    for j in range(blocks_number_loc - 1):
+        iterable_block = seq_input_loc.get_block(block_index=j + 1)
+        if iterable_block.adc is not None:
+            is_adc_inside = True
+            rise_time = iterable_block.gx.rise_time
+            fall_time = iterable_block.gx.fall_time
+    if not is_adc_inside:
+        raise Exception("No ADC event found inside sequence")
+    return rise_time, fall_time
+
+
+def adc_event_edges(local_gate_adc):
+    """
+    Helper function that rise numbers of blocks of border  correction of ADC events
+    Вспомогательная функция для получения номеров блоков границ коррекции АЦП событий
+    :return:    num_begin_l:    int, number of time block when adc event starts
+                                номер временного блока начала АЦП события
+                num_finish_l:   int, same but ends
+                                то же, но для окончания
+    """
+    num_begin_l = 0
+    flag_begin = False
+    flag_finish = False
+    num_finish_l = 1
+    for k in range(len(local_gate_adc) - 1):
+        if local_gate_adc[k] != 0 and not flag_begin:
+            num_begin_l = k
+            flag_begin = True
+        if local_gate_adc[k] != 0 and local_gate_adc[k + 1] == 0 and not flag_finish:
+            num_finish_l = k
+            flag_finish = True
+    return num_begin_l, num_finish_l
+
+
+def plot_sequence_to_cmd(sequence):
+    sequence.plot(time_range=(0, 0.1))
+
+
+def synchronization(sync_sequence, synchro_block_timer=20e-9, path='test1/', TR_DELAY_L=800e-9, RF_DELAY_L=800e-9,
+                    START_DELAY_L=1600e-9, plot_sequence=False):
+    ### MAIN LOOP ###
+    ### ОСНОВНОЙ ЦИКЛ###
+    MIN_BLOCK_TIME = 800e-9
+    assert START_DELAY_L >= RF_DELAY_L
+    assert TR_DELAY_L >= synchro_block_timer
+    assert RF_DELAY_L >= synchro_block_timer
+    number_of_blocks = len(sync_sequence.block_events)
+    gate_adc = [0]
+    gate_rf = [0]
+    gate_tr_switch = [1]
+    blocks_duration = [START_DELAY_L]
+    adc_times_values = []
+    adc_times_starts = []
+    '''
+    ??? RF  GX  GY  GZ  ADC  EXT
+    0    1   2   3   4   5    6
+    '''
+
+    if plot_sequence:
+        plot_sequence_to_cmd(sync_sequence)
+    added_blocks = 0
+    for block_counter in range(number_of_blocks):
+        is_not_adc_block = True
+        a = sync_sequence.block_events[block_counter + 1][5]
+        if sync_sequence.block_events[block_counter + 1][5]:
+            is_not_adc_block = False
+
+            gate_adc.append(0)
+            gate_rf.append(gate_rf[-1])
+            blocks_duration[-1] -= TR_DELAY_L
+            blocks_duration.append(TR_DELAY_L)
+            gate_tr_switch.append(0)
+            added_blocks += 1
+
+            gate_adc.append(1)
+            gate_tr_switch.append(0)
+        else:
+            gate_tr_switch.append(1)
+            gate_adc.append(0)
+
+        if sync_sequence.block_events[block_counter + 1][1] and is_not_adc_block:
+            #TODO: а что делать если и РЧ и АЦП в одном блоке?
+            gate_rf.append(1)
+            gate_adc.append(gate_adc[-1])
+            blocks_duration[-1] -= RF_DELAY_L
+            blocks_duration.append(RF_DELAY_L)
+            gate_tr_switch.append(gate_tr_switch[-1])
+            added_blocks += 1
+
+            gate_rf.append(1)
+
+        else:
+            #print("Incorrect pulse sequence: ADC and RF events are taking place together in one block.")
+            gate_rf.append(0)
+
+        current_block_dur = sync_sequence.block_durations[block_counter + 1]
+        blocks_duration.append(current_block_dur)
+
+    number_of_blocks += added_blocks
+    # gate_gx = [1] * number_of_blocks
+    # gate_gy = [1] * number_of_blocks
+    # gate_gz = [1] * number_of_blocks
+    '''
+    test1 swap
+    '''
+    # assert any(block_times) < MIN_BLOCK_TIME, "ERROR: events in the current sequence are less than 400 ns"
+
+    doc, tag, text = Doc().tagtext()
+    with tag('root'):
+        with tag('ParamCount'):
+            text(number_of_blocks + 1)
+        # Should be added an empty block to turn the gradient amplifier on
+        with tag('RF'):
+            with tag('RF1'):
+                text(0)
+            for RF_iter in range(1, number_of_blocks+1):
+                with tag('RF' + str(RF_iter + 2)):
+                    text(gate_rf[RF_iter])
+        with tag('SW'):
+            with tag('SW1'):
+                text(1)
+            for SW_iter in range(1, number_of_blocks+1):
+                with tag('SW' + str(SW_iter + 2)):
+                    text(gate_tr_switch[SW_iter])
+        with tag('ADC'):
+            with tag('ADC1'):
+                text(0)
+            for ADC_iter in range(1, number_of_blocks+1):
+                if gate_adc[ADC_iter] == 1:
+                    adc_times_values.append(blocks_duration[ADC_iter])
+                    adc_times_starts.append(sum(blocks_duration[0:ADC_iter]))
+                with tag('ADC' + str(ADC_iter + 2)):
+                    text(gate_adc[ADC_iter])
+        with tag('GR'):
+            with tag('GR1'):
+                text(1)
+            for GX_iter in range(1, number_of_blocks+1):
+                with tag('GR' + str(GX_iter + 2)):
+                    text(0)
+        with tag('CL'):
+            with tag('CL1'):
+                text(int(MIN_BLOCK_TIME / synchro_block_timer))
+            for CL_iter in range(1, number_of_blocks+1):
+                with tag('CL' + str(CL_iter + 2)):
+                    text(int(blocks_duration[CL_iter] / synchro_block_timer))
+
+    result = indent(
+        doc.getvalue(),
+        indentation=' ' * 4,
+        newline='\r'
+    )
+    sync_file = open(path + "sync_v2.xml", "w")
+    sync_file.write(result)
+    sync_file.close()
+
+    picoscope_set(adc_times_values, adc_times_starts, path=path)
+
+
+def picoscope_set(adc_val, adc_start, number_of_channels_l=8, sampling_freq_l=4e7, path='test1/'):
+    # sampling rate = 40 MHz = 4e7 1/s
+    # adc_val in seconds
+    adc_out_timings = []
+    for i in adc_val:
+        adc_out_timings.append(int(i * sampling_freq_l))
+
+    doc, tag, text, line = Doc().ttl()
+    with tag('root'):
+        with tag('points'):
+            with tag('title'):
+                text("Points")
+            with tag('value'):
+                text(str(adc_out_timings))
+        with tag('num_of_channels'):
+            with tag('title'):
+                text("Number of Channels")
+            with tag('value'):
+                text(number_of_channels_l)
+        with tag('times'):
+            with tag('title'):
+                text("Times")
+            with tag('value'):
+                text(str([float(i) for i in adc_start]))
+        with tag('sample_freq'):
+            with tag('title'):
+                text("Sample Frequency")
+            with tag('value'):
+                text(sampling_freq_l)
+
+    result = indent(
+        doc.getvalue(),
+        indentation=' ' * 4,
+        newline='\r'
+    )
+    sync_file = open(path + "picoscope_params.xml", "w")
+    sync_file.write(result)
+    sync_file.close()
+
+
+if __name__ == "__main__":
+    CONST_HACK_RF_DELAY = 17 * 2 * 2
+    SEQ_INPUT, SEQ_DICT = seq_file_input(seq_file_name='sequences/TSE_T2w_250_4970_10_300.seq')
+    # SEQ_INPUT, SEQ_DICT = seq_file_input(seq_file_name='sequences/test1_full.seq')
+    # SEQ_INPUT, SEQ_DICT = seq_file_input(seq_file_name='sequences/turbo_FLASH_060924_0444.seq')
+
+    params_path = 'sequences/'
+    params_filename = "TSE_Ksenia_128_PDw"
+    # params_filename = "test1_full"
+    # params_filename = "turbo_FLASH_060924_0444"
+
+    file = open(params_path + params_filename + ".json", 'r')
+    SEQ_PARAM = json.load(file)
+
+    file.close()
+
+    '''
+    integartion of srv_seq_gen
+    '''
+    # SEQ_PARAM = set_limits()
+    # SEQ_INPUT = save_param()
+    # SEQ_DICT = SEQ_INPUT.waveforms_export()
+    '''
+    simulation of inputing the JSON and SEQ
+    '''
+
+    # artificial delays due to construction of the MRI
+    # искусственные задержки из-за тех. особенностей МРТ
+    # RF_dtime = 10 * 1e-6
+    # TR_dtime = 10 * 1e-6
+
+    time_info = SEQ_INPUT.duration()
+    blocks_number = time_info[1]
+    time_dur = time_info[0]
+
+    # output interpretation. all formats of files defined in method
+    # интерпретация выхода. Все форматы файлов определены в методе
+    output_seq(SEQ_DICT, SEQ_PARAM, SEQ_INPUT)
+
+    # defining constants of the sequence
+    # определение констант последовательности
+    local_definitions = SEQ_INPUT.definitions
+    ADC_raster = local_definitions['AdcRasterTime']
+    RF_raster = local_definitions['RadiofrequencyRasterTime']
+
+    synchronization(SEQ_INPUT, plot_sequence=True)

+ 0 - 0
services/seq-interp/src/gui/__init__.py


+ 210 - 0
services/seq-interp/src/gui/adapters.py

@@ -0,0 +1,210 @@
+"""
+Data conversion helpers: seq_data / sync_data → GUI-friendly structures.
+No Qt imports — safe to import in tests.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import List, Optional
+
+import numpy as np
+
+
+@dataclass
+class BlockRow:
+    sync_index: int
+    orig_index: int          # -1 for inserted delay blocks
+    duration: float          # seconds
+    block_type: str
+    has_rf: bool
+    has_adc: bool
+    has_grad: bool
+    is_delay: bool
+    delay_type: str          # "START" | "RF" | "TR" | ""
+    gate_rf: int
+    gate_adc: int
+    gate_tr: int
+    t_start: float           # cumulative start time (s)
+    t_end: float             # cumulative end time (s)
+
+
+def build_block_rows(seq_data: dict, sync_data: dict) -> List[BlockRow]:
+    """
+    Reconstruct the mapping between sync blocks and original sequence blocks.
+
+    The Synchronizer inserts:
+      - one START_DELAY block at position 0
+      - one TR_DELAY block before each ADC block
+      - one RF_DELAY block before each RF (non-ADC) block
+
+    Returns one BlockRow per entry in sync_data["blocks_duration"].
+    """
+    blocks = seq_data.get("blocks", [])
+    gate_adc = sync_data.get("gate_adc", [])
+    gate_rf = sync_data.get("gate_rf", [])
+    gate_tr = sync_data.get("gate_tr_switch", [])
+    durs = sync_data.get("blocks_duration", [])
+
+    if not durs:
+        return []
+
+    cumtimes = np.cumsum([0.0] + list(durs))
+
+    def _row(si: int, oi: int, btype: str,
+             has_rf=False, has_adc=False, has_grad=False,
+             is_delay=False, delay_type="") -> BlockRow:
+        return BlockRow(
+            sync_index=si,
+            orig_index=oi,
+            duration=float(durs[si]) if si < len(durs) else 0.0,
+            block_type=btype,
+            has_rf=has_rf,
+            has_adc=has_adc,
+            has_grad=has_grad,
+            is_delay=is_delay,
+            delay_type=delay_type,
+            gate_rf=int(gate_rf[si]) if si < len(gate_rf) else 0,
+            gate_adc=int(gate_adc[si]) if si < len(gate_adc) else 0,
+            gate_tr=int(gate_tr[si]) if si < len(gate_tr) else 0,
+            t_start=float(cumtimes[si]),
+            t_end=float(cumtimes[si + 1]) if si + 1 < len(cumtimes) else float(cumtimes[-1]),
+        )
+
+    rows: List[BlockRow] = []
+
+    # Index 0 is always the START_DELAY block
+    r = _row(0, -1, "START_DELAY", is_delay=True, delay_type="START")
+    rows.append(r)
+
+    si = 1
+    for oi, blk in enumerate(blocks):
+        if si >= len(durs):
+            break
+
+        has_adc = blk.get("has_adc", False)
+        has_rf = "RF" in blk.get("type", [])
+        has_grad = "GRAD" in blk.get("type", [])
+
+        # Inserted delay before this block
+        if has_adc:
+            rows.append(_row(si, oi, "TR_DELAY", is_delay=True, delay_type="TR"))
+            si += 1
+        elif has_rf:
+            rows.append(_row(si, oi, "RF_DELAY", is_delay=True, delay_type="RF"))
+            si += 1
+
+        if si >= len(durs):
+            break
+
+        btype = ", ".join(blk.get("type", [])) or "DELAY"
+        rows.append(_row(si, oi, btype,
+                         has_rf=has_rf, has_adc=has_adc, has_grad=has_grad))
+        si += 1
+
+    return rows
+
+
+def gate_to_step(gate: list, durs: list):
+    """
+    Convert gate signal + block durations to (t, v) arrays for step-function plot.
+    Returns two numpy arrays of shape (2*n,).
+    """
+    n = min(len(gate), len(durs))
+    cumtimes = np.cumsum([0.0] + list(durs[:n]))
+    t = np.empty(n * 2)
+    v = np.empty(n * 2, dtype=float)
+    for i in range(n):
+        t[2 * i] = cumtimes[i]
+        t[2 * i + 1] = cumtimes[i + 1]
+        v[2 * i] = gate[i]
+        v[2 * i + 1] = gate[i]
+    return t, v
+
+
+def block_cumtimes(durs: list) -> np.ndarray:
+    """Cumulative time boundaries from blocks_duration list."""
+    return np.cumsum([0.0] + list(durs))
+
+
+def find_block_at_time(t: float, block_rows: List[BlockRow]) -> Optional[BlockRow]:
+    """Return the BlockRow whose [t_start, t_end) interval contains t."""
+    for row in block_rows:
+        if row.t_start <= t < row.t_end:
+            return row
+    return None
+
+
+def validate_timing(hw, seq_data: dict, sync_data: dict) -> List[str]:
+    """Return a list of human-readable warning strings."""
+    warnings: List[str] = []
+    mbd = hw.MIN_BLOCK_DURATION
+
+    for name, val in [("RF_DELAY", hw.RF_DELAY),
+                      ("TR_DELAY", hw.TR_DELAY),
+                      ("START_DELAY", hw.START_DELAY)]:
+        if val < mbd:
+            warnings.append(
+                f"{name} ({val * 1e9:.1f} ns) < MIN_BLOCK_DURATION ({mbd * 1e9:.1f} ns)"
+            )
+
+    timer = sync_data.get("synchro_block_timer", mbd)
+    for i, dur in enumerate(sync_data.get("blocks_duration", [])):
+        if dur > 0 and timer > 0:
+            cl = dur / timer
+            if int(cl) == 0:
+                warnings.append(
+                    f"Sync block {i}: CL rounds to zero (dur={dur * 1e9:.1f} ns)"
+                )
+
+    # Raster alignment — warn once if any block violates
+    raster = hw.block_duration_raster
+    if raster > 0:
+        for i, dur in enumerate(sync_data.get("blocks_duration", [])):
+            if dur > 0:
+                rem = dur % raster
+                if 1e-15 < rem < raster - 1e-15:
+                    warnings.append(
+                        f"Block {i}: duration {dur * 1e6:.3f} µs not aligned to "
+                        f"block_duration_raster {raster * 1e6:.3f} µs (first occurrence)"
+                    )
+                    break
+
+    for key in ["rf", "t_rf"]:
+        if key not in seq_data:
+            warnings.append(f"Waveform '{key}' not found in sequence data")
+
+    if "rf" in seq_data and "t_rf" in seq_data:
+        if len(seq_data["rf"]) != len(seq_data["t_rf"]):
+            warnings.append(
+                f"RF length mismatch: rf={len(seq_data['rf'])}, t_rf={len(seq_data['t_rf'])}"
+            )
+
+    for axis in ["gx", "gy", "gz"]:
+        t_key = f"t_{axis}"
+        if axis in seq_data and t_key in seq_data:
+            if len(seq_data[axis]) != len(seq_data[t_key]):
+                warnings.append(f"{axis.upper()} length mismatch")
+
+    return warnings
+
+
+def seq_metadata(seq_data: dict, hw) -> dict:
+    """Return a flat dict of human-readable sequence metadata."""
+    blocks = seq_data.get("blocks", [])
+    params = seq_data.get("params", {})
+    return {
+        "Total blocks (orig)": len(blocks),
+        "RF blocks": sum(1 for b in blocks if "RF" in b.get("type", [])),
+        "ADC blocks": sum(1 for b in blocks if b.get("has_adc", False)),
+        "Grad blocks": sum(1 for b in blocks if "GRAD" in b.get("type", [])),
+        "RF raster (µs)": f"{hw.rf_raster_time * 1e6:.4f}",
+        "Grad raster (µs)": f"{hw.grad_raster_time * 1e6:.4f}",
+        "ADC raster (ns)": f"{hw.adc_raster_time * 1e9:.4f}",
+        "Block raster (µs)": f"{hw.block_duration_raster * 1e6:.4f}",
+        "RF delay (ns)": f"{hw.RF_DELAY * 1e9:.1f}",
+        "TR delay (ns)": f"{hw.TR_DELAY * 1e9:.1f}",
+        "Start delay (µs)": f"{hw.START_DELAY * 1e6:.4f}",
+        "Min block dur (ns)": f"{hw.MIN_BLOCK_DURATION * 1e9:.1f}",
+        "Gamma (MHz/T)": f"{hw.gamma / 1e6:.4f}",
+        "RF scale": f"{params.get('scale_rf', 1.0):.4f}",
+    }

+ 150 - 0
services/seq-interp/src/gui/block_table.py

@@ -0,0 +1,150 @@
+"""
+Block table widget — one row per sync block, colour-coded by type.
+Row tints use alpha-transparent QColor values so they blend with whatever
+base colour the OS palette provides, keeping type indication readable on
+both light and dark themes.
+"""
+from __future__ import annotations
+
+from PySide6.QtCore import Signal, Qt
+from PySide6.QtWidgets import (
+    QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
+)
+from PySide6.QtGui import QColor, QBrush
+
+from seq_interp.src.gui.adapters import BlockRow
+
+_COL_NAMES = [
+    "#sync", "orig#", "Type", "Dur (µs)", "RF", "ADC", "Grad",
+    "Delay", "gRF", "gADC", "gTR", "T-start (µs)",
+]
+
+# Row background tints — alpha ~80/255 so they blend with the OS base colour.
+# On a white base: soft pastels.  On a dark base: subtle dark tints.
+_BG: dict[str, QColor] = {
+    "START": QColor(244, 143, 177,  80),  # pink  — start delay
+    "TR":    QColor(144, 202, 249,  80),  # blue  — TR delay
+    "RF":    QColor(255, 224, 130,  80),  # amber — RF delay
+    "adc":   QColor(165, 214, 167,  80),  # green — ADC block
+    "rf":    QColor(244, 143, 177,  80),  # pink  — RF block
+    "grad":  QColor(206, 147, 216,  80),  # purple — gradient block
+    "plain": QColor(0,   0,   0,    0 ),  # transparent — no tint
+}
+
+# Structural stylesheet only — base text/background come from the OS palette.
+# Only the selection highlight and hover state are pinned to explicit colours
+# because they need to be clearly visible on any background.
+_STYLESHEET = """
+QTableWidget {
+    gridline-color: palette(mid);
+    font-size: 12px;
+}
+QTableWidget::item {
+    padding: 2px 4px;
+}
+QTableWidget::item:selected {
+    background-color: #1565c0;
+    color: #ffffff;
+}
+QTableWidget::item:hover:!selected {
+    background-color: rgba(144, 202, 249, 60);
+}
+QHeaderView::section {
+    border: none;
+    border-bottom: 1px solid palette(mid);
+    border-right:  1px solid palette(mid);
+    padding: 4px 6px;
+    font-weight: bold;
+}
+QScrollBar:horizontal, QScrollBar:vertical {
+    background: palette(alternateBase);
+}
+"""
+
+
+def _bg_for(row: BlockRow) -> QColor:
+    if row.is_delay:
+        return _BG.get(row.delay_type, _BG["plain"])
+    if row.has_adc:
+        return _BG["adc"]
+    if row.has_rf:
+        return _BG["rf"]
+    if row.has_grad:
+        return _BG["grad"]
+    return _BG["plain"]
+
+
+class BlockTable(QTableWidget):
+    blockSelected = Signal(int)   # sync_index
+
+    def __init__(self, parent=None):
+        super().__init__(0, len(_COL_NAMES), parent)
+        self.setHorizontalHeaderLabels(_COL_NAMES)
+        self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
+        self.setSelectionBehavior(QAbstractItemView.SelectRows)
+        self.setEditTriggers(QAbstractItemView.NoEditTriggers)
+        self.setAlternatingRowColors(False)
+        self.verticalHeader().setDefaultSectionSize(20)
+        self.verticalHeader().setVisible(False)
+        self.setStyleSheet(_STYLESHEET)
+        self._rows: list[BlockRow] = []
+        self._suppress = False
+        self.itemSelectionChanged.connect(self._on_selection)
+
+    # ── public ────────────────────────────────────────────────────────────────
+
+    def load_rows(self, rows: list[BlockRow]) -> None:
+        self._rows = rows
+        self.setRowCount(0)
+        self.setRowCount(len(rows))
+        for r, row in enumerate(rows):
+            bg = QBrush(_bg_for(row))
+            vals = [
+                str(row.sync_index),
+                str(row.orig_index) if row.orig_index >= 0 else "—",
+                row.block_type,
+                f"{row.duration * 1e6:.3f}",
+                "✓" if row.has_rf else "",
+                "✓" if row.has_adc else "",
+                "✓" if row.has_grad else "",
+                row.delay_type if row.is_delay else "",
+                str(row.gate_rf),
+                str(row.gate_adc),
+                str(row.gate_tr),
+                f"{row.t_start * 1e6:.3f}",
+            ]
+            for c, val in enumerate(vals):
+                item = QTableWidgetItem(val)
+                item.setTextAlignment(Qt.AlignCenter)
+                item.setBackground(bg)
+                # Foreground intentionally not set: the OS palette Text role
+                # provides the correct readable colour for the current theme.
+                self.setItem(r, c, item)
+
+    def select_by_sync_index(self, sync_index: int) -> None:
+        self._suppress = True
+        try:
+            for r, row in enumerate(self._rows):
+                if row.sync_index == sync_index:
+                    self.selectRow(r)
+                    self.scrollTo(self.model().index(r, 0))
+                    break
+        finally:
+            self._suppress = False
+
+    def row_for_sync_index(self, sync_index: int) -> BlockRow | None:
+        for row in self._rows:
+            if row.sync_index == sync_index:
+                return row
+        return None
+
+    # ── private ───────────────────────────────────────────────────────────────
+
+    def _on_selection(self) -> None:
+        if self._suppress:
+            return
+        selected = self.selectionModel().selectedRows()
+        if selected:
+            r = selected[0].row()
+            if r < len(self._rows):
+                self.blockSelected.emit(self._rows[r].sync_index)

+ 137 - 0
services/seq-interp/src/gui/controls_panel.py

@@ -0,0 +1,137 @@
+"""
+Left-panel widget: editable hardware delay / raster spinboxes.
+"""
+from __future__ import annotations
+
+import json
+
+from PySide6.QtCore import Signal
+from seq_interp.src.gui.scheme_panel import system_is_dark
+from PySide6.QtWidgets import (
+    QWidget, QVBoxLayout, QGroupBox, QFormLayout,
+    QDoubleSpinBox, QPushButton, QGridLayout, QFileDialog,
+)
+
+
+# (attr_name, label, unit_suffix, scale_to_unit, min_val, max_val, step)
+_FIELDS = [
+    ("RF_DELAY",             "RF Delay",        "ns",  1e9,  0.0, 1e7,  1.0),
+    ("TR_DELAY",             "TR Delay",         "ns",  1e9,  0.0, 1e7,  1.0),
+    ("START_DELAY",          "Start Delay",      "ns",  1e9,  0.0, 1e7,  10.0),
+    ("MIN_BLOCK_DURATION",   "Min Block Dur",    "ns",  1e9,  0.0, 1e7,  1.0),
+    ("rf_raster_time",       "RF Raster",        "µs",  1e6,  0.001, 100.0, 0.01),
+    ("grad_raster_time",     "Grad Raster",      "µs",  1e6,  0.001, 1000.0, 0.1),
+    ("adc_raster_time",      "ADC Raster",       "ns",  1e9,  0.001, 1e6,  1.0),
+    ("block_duration_raster","Block Raster",     "µs",  1e6,  0.001, 100.0, 0.01),
+]
+
+
+class DelayControlsPanel(QWidget):
+    rerun = Signal()        # user clicked "Apply & Rerun"
+    reloadConfig = Signal() # user clicked "Reload Config"
+    saveConfig = Signal()   # user clicked "Save HW Config"
+
+    def __init__(self, parent: QWidget | None = None):
+        super().__init__(parent)
+        outer = QVBoxLayout(self)
+        outer.setContentsMargins(4, 4, 4, 4)
+
+        grp = QGroupBox("Hardware Delays / Rasters")
+        form = QFormLayout(grp)
+        form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+
+        self._spinboxes: dict[str, tuple[QDoubleSpinBox, float]] = {}
+        self._defaults: dict[str, float] = {}
+
+        for attr, label, unit, scale, mn, mx, step in _FIELDS:
+            sb = QDoubleSpinBox()
+            sb.setDecimals(3)
+            sb.setSuffix(f"  {unit}")
+            sb.setRange(mn, mx)
+            sb.setSingleStep(step)
+            sb.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
+            sb.valueChanged.connect(self._mark_modified)
+            form.addRow(f"{label}:", sb)
+            self._spinboxes[attr] = (sb, scale)
+
+        outer.addWidget(grp)
+
+        # 2×2 grid: prevents text overflow on narrow left panel (min 220 px)
+        self.btn_apply  = QPushButton("Apply && Rerun")
+        self.btn_reset  = QPushButton("Reset")
+        self.btn_reload = QPushButton("Reload Config")
+        self.btn_save   = QPushButton("Save HW Config")
+        grid = QGridLayout()
+        grid.setSpacing(4)
+        grid.addWidget(self.btn_apply,  0, 0)
+        grid.addWidget(self.btn_reset,  0, 1)
+        grid.addWidget(self.btn_reload, 1, 0)
+        grid.addWidget(self.btn_save,   1, 1)
+        outer.addLayout(grid)
+
+        self.btn_apply.clicked.connect(self.rerun)
+        self.btn_reload.clicked.connect(self.reloadConfig)
+        self.btn_reset.clicked.connect(self._on_reset)
+        self.btn_save.clicked.connect(self._on_save)
+        outer.addStretch()
+
+    # ------------------------------------------------------------------
+    # Public API
+    # ------------------------------------------------------------------
+
+    def load_from_hw(self, hw) -> None:
+        for attr, (sb, scale) in self._spinboxes.items():
+            val = getattr(hw, attr, 0.0)
+            self._defaults[attr] = val
+            sb.blockSignals(True)
+            sb.setValue(val * scale)
+            sb.blockSignals(False)
+            sb.setStyleSheet("")
+
+    def get_overrides(self) -> dict:
+        """Return dict of {attr: value_in_SI_units}."""
+        return {attr: sb.value() / scale
+                for attr, (sb, scale) in self._spinboxes.items()}
+
+    # ------------------------------------------------------------------
+    # Private helpers
+    # ------------------------------------------------------------------
+
+    def _mark_modified(self) -> None:
+        sender = self.sender()
+        for attr, (sb, scale) in self._spinboxes.items():
+            if sb is sender:
+                default = self._defaults.get(attr)
+                is_modified = (default is not None and
+                               abs(sb.value() / scale - default) > 1e-15)
+                if is_modified:
+                    bg = "#2a2400" if system_is_dark() else "#fffde7"
+                    sb.setStyleSheet(f"background-color: {bg};")
+                else:
+                    sb.setStyleSheet("")
+                break
+
+    def _on_reset(self) -> None:
+        for attr, (sb, scale) in self._spinboxes.items():
+            default = self._defaults.get(attr)
+            if default is not None:
+                sb.blockSignals(True)
+                sb.setValue(default * scale)
+                sb.blockSignals(False)
+                sb.setStyleSheet("")
+
+    def _on_save(self) -> None:
+        path, _ = QFileDialog.getSaveFileName(
+            self, "Save hardware config", "hw_config.json",
+            "JSON files (*.json)"
+        )
+        if not path:
+            return
+        overrides = self.get_overrides()
+        try:
+            with open(path, "w", encoding="utf-8") as fh:
+                json.dump(overrides, fh, indent=2)
+        except OSError as exc:
+            from PySide6.QtWidgets import QMessageBox
+            QMessageBox.critical(self, "Save failed", str(exc))
+        self.saveConfig.emit()

+ 600 - 0
services/seq-interp/src/gui/main_window.py

@@ -0,0 +1,600 @@
+"""
+Main application window for the MRI sequence interpreter GUI.
+
+Layout
+──────
+[Button bar]                    ← QPushButton row, never clips text
+┌─────────────┬────────────────────────────┬──────────────────────┐
+│ Left        │ Centre                     │ Right                │
+│ ─────────── │ [Sequence scheme]          │ Block Details        │
+│ Metadata    │ ─────────────────────────  │ Warnings             │
+│ HW delays   │ Waveform plots             │ Sync XML             │
+│ Warnings    │                            │ POST JSON            │
+│             │ [Block table — hidden]     │ Log                  │
+└─────────────┴────────────────────────────┴──────────────────────┘
+
+Design rules
+────────────
+• All heavy work runs in QThread workers — UI thread stays responsive.
+• Block table is hidden by default; toggled with the "Blocks" button.
+• Log output lives in the right panel (PreviewPanel Log tab).
+• Sequence load state is shown as a coloured status indicator in the button bar.
+• hw_config.json is never silently overwritten.
+"""
+from __future__ import annotations
+
+import logging
+import os
+from datetime import datetime
+
+from PySide6.QtCore import Qt, QSize
+from PySide6.QtGui import QFont, QColor
+from PySide6.QtWidgets import (
+    QApplication, QMainWindow, QWidget, QSplitter, QVBoxLayout, QHBoxLayout,
+    QGroupBox, QFormLayout, QLabel, QListWidget, QListWidgetItem,
+    QFrame, QPushButton, QProgressBar, QStatusBar,
+    QMessageBox, QScrollArea, QSizePolicy, QFileDialog,
+)
+
+from seq_interp.src.gui.adapters import (
+    build_block_rows, seq_metadata, validate_timing, find_block_at_time,
+)
+from seq_interp.src.gui.block_table import BlockTable
+from seq_interp.src.gui.controls_panel import DelayControlsPanel
+from seq_interp.src.gui.plot_panel import PlotPanel
+from seq_interp.src.gui.preview_panel import PreviewPanel
+from seq_interp.src.gui.scheme_panel import SchemePanel, system_is_dark
+from seq_interp.src.gui.workers import (
+    LoadInterpWorker, SyncOnlyWorker, ExportWorker, XmlPreviewWorker,
+)
+
+# ── loading-state style maps (light / dark) ───────────────────────────────────
+
+_STATE_LIGHT: dict[str, tuple[str, str]] = {
+    "idle":     ("#757575", "●"),
+    "selected": ("#1565c0", "●"),
+    "loading":  ("#e65100", "⟳"),
+    "loaded":   ("#2e7d32", "✓"),
+    "failed":   ("#c62828", "✗"),
+}
+
+_STATE_DARK: dict[str, tuple[str, str]] = {
+    "idle":     ("#9e9e9e", "●"),
+    "selected": ("#64b5f6", "●"),
+    "loading":  ("#ff9800", "⟳"),
+    "loaded":   ("#81c784", "✓"),
+    "failed":   ("#ef9a9a", "✗"),
+}
+
+
+class MainWindow(QMainWindow):
+    def __init__(self):
+        super().__init__()
+        self.setWindowTitle("MRI Sequence Interpreter")
+        self.setMinimumSize(900, 600)
+
+        # Size relative to available screen so we never start off-screen on
+        # small monitors (e.g. 1366×768 laptops).  Cap at 1600×940.
+        screen = QApplication.primaryScreen()
+        if screen is not None:
+            ag = screen.availableGeometry()
+            w = min(1600, max(900,  int(ag.width()  * 0.92)))
+            h = min(940,  max(600,  int(ag.height() * 0.90)))
+            self.resize(w, h)
+        else:
+            self.resize(1600, 940)
+
+        # ── application state ──────────────────────────────────────────
+        self._seq_path: str | None = None
+        self._hw_config_path: str | None = None
+        self._output_dir: str | None = None
+        self._seq_data: dict | None = None
+        self._sync_data: dict | None = None
+        self._hw = None
+        self._block_rows: list = []
+        self._worker = None
+        self._xml_preview_worker = None
+        self._pending_table_select: int | None = None   # remembered when table hidden
+
+        # ── build UI ───────────────────────────────────────────────────
+        central = QWidget()
+        self.setCentralWidget(central)
+        root_layout = QVBoxLayout(central)
+        root_layout.setContentsMargins(0, 0, 0, 0)
+        root_layout.setSpacing(0)
+        root_layout.addWidget(self._build_button_bar())
+        root_layout.addWidget(self._build_seq_status_bar())
+        root_layout.addWidget(self._build_main_splitter(), stretch=1)
+
+        self._build_statusbar()
+        self._setup_logging()
+
+    # ================================================================== #
+    #  Button bar (replaces QToolBar — never clips text)                  #
+    # ================================================================== #
+
+    def _build_button_bar(self) -> QWidget:
+        bar = QWidget()
+        bar.setObjectName("ButtonBar")
+        bar.setStyleSheet(
+            "#ButtonBar { background: palette(window); border-bottom: 1px solid palette(mid); }"
+        )
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(6, 4, 6, 4)
+        lay.setSpacing(4)
+
+        def sep() -> QFrame:
+            f = QFrame()
+            f.setFrameShape(QFrame.VLine)
+            f.setFrameShadow(QFrame.Sunken)
+            f.setFixedWidth(2)
+            return f
+
+        def btn(label: str, tip: str, slot=None, enabled: bool = True) -> QPushButton:
+            b = QPushButton(label)
+            b.setToolTip(tip)
+            b.setEnabled(enabled)
+            b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+            if slot:
+                b.clicked.connect(slot)
+            lay.addWidget(b)
+            return b
+
+        self._btn_load_seq   = btn("📂 Load .seq",  "Open Pulseq .seq file",              self._load_seq)
+        self._btn_load_hw    = btn("⚙ HW Config",   "Load hardware constraints JSON",      self._load_hw_config)
+        self._btn_out_dir    = btn("📁 Output Dir",  "Choose output directory",             self._choose_output_dir)
+        lay.addWidget(sep())
+        self._btn_run        = btn("▶ Run",          "Run interpretation pipeline",         self._run,    enabled=False)
+        self._btn_export     = btn("💾 Export",      "Export all artifacts to output dir",  self._export, enabled=False)
+        lay.addWidget(sep())
+        self._btn_fit        = btn("🔍 Fit All",     "Fit all plots to data",               lambda: self._plots.fit_all())
+        self._btn_blocks     = btn("📋 Blocks ▼",    "Toggle block table open/closed",      self._toggle_table)
+
+        lay.addStretch()
+
+        self._progress = QProgressBar()
+        self._progress.setRange(0, 0)
+        self._progress.setFixedWidth(120)
+        self._progress.setVisible(False)
+        lay.addWidget(self._progress)
+
+        return bar
+
+    def _build_seq_status_bar(self) -> QWidget:
+        bar = QWidget()
+        bar.setObjectName("SeqStatus")
+        bar.setStyleSheet(
+            "#SeqStatus { background: palette(window); border-bottom: 1px solid palette(mid); }"
+        )
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(8, 2, 8, 2)
+        self._seq_status = QLabel("  ● No file selected")
+        self._seq_status.setFont(QFont("Arial", 9))
+        self._seq_status.setStyleSheet("color: #9e9e9e;")
+        lay.addWidget(self._seq_status)
+        lay.addStretch()
+        return bar
+
+    # ================================================================== #
+    #  Main three-panel splitter                                           #
+    # ================================================================== #
+
+    def _build_main_splitter(self) -> QSplitter:
+        root = QSplitter(Qt.Horizontal)
+
+        root.addWidget(self._build_left_panel())
+        root.addWidget(self._build_centre_panel())
+
+        self._preview = PreviewPanel()
+        self._preview.setMinimumWidth(260)
+        self._preview.setMaximumWidth(480)
+        root.addWidget(self._preview)
+
+        root.setSizes([280, 1040, 280])
+
+        # Wire signals
+        self._plots.blockClicked.connect(self._on_block_from_plot)
+        self._plots.timeHovered.connect(self._on_hover)
+        self._table.blockSelected.connect(self._on_block_from_table)
+        self._scheme.blockClicked.connect(self._on_block_from_scheme)
+        self._controls.rerun.connect(self._rerun)
+        self._controls.reloadConfig.connect(self._reload_hw_config)
+
+        return root
+
+    # ── left panel ────────────────────────────────────────────────────────────
+
+    def _build_left_panel(self) -> QScrollArea:
+        left = QWidget()
+        left.setMinimumWidth(220)
+        left.setMaximumWidth(360)
+        lay = QVBoxLayout(left)
+        lay.setContentsMargins(4, 4, 4, 4)
+        lay.setSpacing(6)
+
+        # Metadata
+        meta_grp = QGroupBox("Sequence Metadata")
+        meta_form = QFormLayout(meta_grp)
+        meta_form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+        self._meta_labels: dict[str, QLabel] = {}
+        for key in [
+            "Total blocks (orig)", "RF blocks", "ADC blocks", "Grad blocks",
+            "RF raster (µs)", "Grad raster (µs)", "ADC raster (ns)",
+            "Block raster (µs)", "RF delay (ns)", "TR delay (ns)",
+            "Start delay (µs)", "Min block dur (ns)", "Gamma (MHz/T)", "RF scale",
+        ]:
+            lbl = QLabel("—")
+            lbl.setFont(QFont("Courier New", 9))
+            meta_form.addRow(QLabel(f"{key}:"), lbl)
+            self._meta_labels[key] = lbl
+        lay.addWidget(meta_grp)
+
+        # Delay controls
+        self._controls = DelayControlsPanel()
+        lay.addWidget(self._controls)
+
+        # Warnings (compact, left-panel copy)
+        warn_grp = QGroupBox("Warnings")
+        warn_lay = QVBoxLayout(warn_grp)
+        self._warn_list = QListWidget()
+        self._warn_list.setFont(QFont("Arial", 9))
+        self._warn_list.setMaximumHeight(110)
+        self._warn_list.setStyleSheet(
+            "QListWidget { color: palette(text); background: palette(alternateBase); } "
+            "QListWidget::item { padding: 2px; }"
+        )
+        warn_lay.addWidget(self._warn_list)
+        lay.addWidget(warn_grp)
+
+        lay.addStretch()
+
+        scroll = QScrollArea()
+        scroll.setWidget(left)
+        scroll.setWidgetResizable(True)
+        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        return scroll
+
+    # ── centre panel ──────────────────────────────────────────────────────────
+
+    def _build_centre_panel(self) -> QSplitter:
+        vsplit = QSplitter(Qt.Vertical)
+        self._centre_vsplit = vsplit   # kept for table-toggle height adjustment
+
+        # Scheme (always visible, compact)
+        self._scheme = SchemePanel()
+        vsplit.addWidget(self._scheme)
+
+        # Plots
+        self._plots = PlotPanel()
+        vsplit.addWidget(self._plots)
+
+        # Block table (hidden by default)
+        self._table_container = QWidget()
+        tc_lay = QVBoxLayout(self._table_container)
+        tc_lay.setContentsMargins(0, 0, 0, 0)
+        self._table = BlockTable()
+        tc_lay.addWidget(self._table)
+        self._table_container.setVisible(False)
+        vsplit.addWidget(self._table_container)
+
+        vsplit.setSizes([64, 700, 0])
+        vsplit.setCollapsible(2, True)
+        return vsplit
+
+    # ================================================================== #
+    #  Status bar                                                          #
+    # ================================================================== #
+
+    def _build_statusbar(self) -> None:
+        sb = QStatusBar()
+        self.setStatusBar(sb)
+        self._status_lbl = QLabel("Ready")
+        sb.addWidget(self._status_lbl)
+
+    def _setup_logging(self) -> None:
+        log_dir = os.path.join(
+            os.path.dirname(os.path.dirname(
+                os.path.dirname(os.path.abspath(__file__))
+            )),
+            "log",
+        )
+        os.makedirs(log_dir, exist_ok=True)
+        ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+        handler = logging.FileHandler(
+            os.path.join(log_dir, f"gui_{ts}.log"), encoding="utf-8"
+        )
+        handler.setFormatter(
+            logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
+        )
+        logging.getLogger().addHandler(handler)
+        logging.getLogger().setLevel(logging.INFO)
+
+    # ================================================================== #
+    #  File / directory actions                                            #
+    # ================================================================== #
+
+    def _load_seq(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Open Pulseq sequence", "",
+            "Pulseq files (*.seq);;All files (*)"
+        )
+        if not path:
+            return
+        self._seq_path = path
+        name = os.path.basename(path)
+        self._set_seq_state("selected", name)
+        self._btn_run.setEnabled(True)
+        self._log(f"Sequence selected: {path}")
+
+    def _load_hw_config(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Open HW config", "",
+            "JSON files (*.json);;All files (*)"
+        )
+        if not path:
+            return
+        self._hw_config_path = path
+        self._log(f"HW config: {path}")
+
+    def _choose_output_dir(self) -> None:
+        path = QFileDialog.getExistingDirectory(self, "Choose output directory")
+        if path:
+            self._output_dir = path
+            self._log(f"Output dir: {path}")
+
+    def _reload_hw_config(self) -> None:
+        if self._hw and self._hw_config_path:
+            self._hw.load_from_json(self._hw_config_path)
+            self._controls.load_from_hw(self._hw)
+            self._log("HW config reloaded from file")
+        elif self._hw:
+            from seq_interp.src.hardware.constraints import HardwareConstraints
+            self._hw = HardwareConstraints()
+            self._controls.load_from_hw(self._hw)
+            self._log("HW config reset to class defaults")
+
+    # ================================================================== #
+    #  Table toggle                                                        #
+    # ================================================================== #
+
+    def _toggle_table(self) -> None:
+        visible = not self._table_container.isVisible()
+        self._table_container.setVisible(visible)
+        self._btn_blocks.setText("📋 Blocks ▲" if visible else "📋 Blocks ▼")
+        if visible:
+            # Splitter stores size 0 for the hidden widget; explicitly give it
+            # 200 px when revealed by taking from the plots section.
+            sizes = self._centre_vsplit.sizes()
+            if sizes[2] < 100:
+                target   = 200
+                plots_h  = max(100, sizes[1] - target)
+                self._centre_vsplit.setSizes([sizes[0], plots_h, target])
+            if self._pending_table_select is not None:
+                self._table.select_by_sync_index(self._pending_table_select)
+                self._pending_table_select = None
+
+    # ================================================================== #
+    #  Run / rerun / export                                                #
+    # ================================================================== #
+
+    def _run(self) -> None:
+        if not self._seq_path:
+            return
+        name = os.path.basename(self._seq_path)
+        self._set_seq_state("loading", name)
+        self._start_busy("Loading and interpreting…")
+        overrides = self._controls.get_overrides() if self._hw else {}
+        self._worker = LoadInterpWorker(
+            self._seq_path,
+            hw_config_path=self._hw_config_path,
+            hw_overrides=overrides,
+        )
+        self._worker.log_msg.connect(self._log)
+        self._worker.finished.connect(self._on_interp_finished)
+        self._worker.error.connect(self._on_worker_error)
+        self._worker.start()
+
+    def _rerun(self) -> None:
+        if self._seq_data is None:
+            self._run()
+            return
+        self._start_busy("Re-synchronizing…")
+        self._worker = SyncOnlyWorker(
+            self._seq_data,
+            hw_config_path=self._hw_config_path,
+            hw_overrides=self._controls.get_overrides(),
+        )
+        self._worker.log_msg.connect(self._log)
+        self._worker.finished.connect(self._on_sync_finished)
+        self._worker.error.connect(self._on_worker_error)
+        self._worker.start()
+
+    def _export(self) -> None:
+        if self._seq_data is None or self._sync_data is None:
+            return
+        out = self._output_dir
+        if not out:
+            out = QFileDialog.getExistingDirectory(self, "Choose output directory")
+            if not out:
+                return
+            self._output_dir = out
+        self._start_busy("Exporting artifacts…")
+        self._worker = ExportWorker(
+            self._seq_data, self._sync_data, self._hw, out
+        )
+        self._worker.log_msg.connect(self._log)
+        self._worker.finished.connect(self._on_export_finished)
+        self._worker.error.connect(self._on_worker_error)
+        self._worker.start()
+
+    # ================================================================== #
+    #  Worker callbacks                                                    #
+    # ================================================================== #
+
+    def _on_interp_finished(self, seq_data: dict, sync_data: dict, hw) -> None:
+        self._seq_data  = seq_data
+        self._sync_data = sync_data
+        self._hw        = hw
+        self._stop_busy()
+        self._apply_results(seq_data, sync_data, hw)
+
+    def _on_sync_finished(self, sync_data: dict, hw) -> None:
+        self._sync_data = sync_data
+        self._hw        = hw
+        self._stop_busy()
+        self._apply_results(self._seq_data, sync_data, hw)
+
+    def _on_export_finished(self, output_dir: str, xml_text: str,
+                             post_text: str) -> None:
+        self._stop_busy()
+        self._preview.set_xml_text(xml_text)
+        self._preview.set_post_json_text(post_text)
+        self._log(f"Export complete → {output_dir}")
+        QMessageBox.information(
+            self, "Export complete", f"Artifacts written to:\n{output_dir}"
+        )
+
+    def _on_worker_error(self, msg: str) -> None:
+        name = os.path.basename(self._seq_path or "")
+        self._set_seq_state("failed", name, msg[:80])
+        self._stop_busy()
+        self._log(f"ERROR: {msg}", error=True)
+        self._preview.add_error(msg)
+        QMessageBox.critical(self, "Error", msg)
+
+    # ================================================================== #
+    #  Results display                                                     #
+    # ================================================================== #
+
+    def _apply_results(self, seq_data: dict, sync_data: dict, hw) -> None:
+        # Metadata labels
+        meta = seq_metadata(seq_data, hw)
+        for key, lbl in self._meta_labels.items():
+            lbl.setText(str(meta.get(key, "—")))
+
+        # Controls — populate once on first load, don't overwrite user edits
+        if not self._controls._defaults:
+            self._controls.load_from_hw(hw)
+
+        # Warnings
+        warnings = validate_timing(hw, seq_data, sync_data)
+        self._refresh_warnings(warnings)
+        self._preview.set_warnings(warnings)
+
+        # Block table and scheme
+        self._block_rows = build_block_rows(seq_data, sync_data)
+        self._table.load_rows(self._block_rows)
+        self._scheme.load_rows(self._block_rows)
+
+        # Plots
+        self._plots.plot_all(seq_data, sync_data)
+
+        # XML / POST preview (async, non-blocking)
+        pw = XmlPreviewWorker(sync_data, hw)
+        pw.finished.connect(
+            lambda xml, post: (
+                self._preview.set_xml_text(xml),
+                self._preview.set_post_json_text(post),
+            )
+        )
+        pw.error.connect(lambda e: self._log(f"XML preview: {e}", error=True))
+        pw.start()
+        self._xml_preview_worker = pw   # keep reference alive
+
+        # Seq status
+        blocks = seq_data.get("blocks", [])
+        total_s = sum(sync_data.get("blocks_duration", []))
+        parts = [f"{len(blocks)} blocks"]
+        if any("RF"   in b.get("type", []) for b in blocks):
+            parts.append("RF")
+        if any(b.get("has_adc") for b in blocks):
+            parts.append("ADC")
+        if any("GRAD" in b.get("type", []) for b in blocks):
+            parts.append("Grad")
+        if total_s >= 1e-3:
+            parts.append(f"{total_s * 1e3:.2f} ms")
+        else:
+            parts.append(f"{total_s * 1e6:.1f} µs")
+        name = os.path.basename(self._seq_path or "")
+        self._set_seq_state("loaded", name, " · ".join(parts))
+
+        self._act_enabled(run=True, export=True)
+        self._status_lbl.setText(
+            f"{len(blocks)} input blocks → {sync_data['number_of_blocks']} sync blocks"
+        )
+
+    def _refresh_warnings(self, warnings: list[str]) -> None:
+        self._warn_list.clear()
+        warn_color = QColor("#ff9800" if system_is_dark() else "#e65100")
+        for w in warnings:
+            item = QListWidgetItem(f"⚠  {w}")
+            item.setForeground(warn_color)
+            self._warn_list.addItem(item)
+
+    # ================================================================== #
+    #  Block selection sync                                                #
+    # ================================================================== #
+
+    def _on_block_from_plot(self, sync_index: int) -> None:
+        self._select_block(sync_index, source="plot")
+
+    def _on_block_from_table(self, sync_index: int) -> None:
+        self._select_block(sync_index, source="table")
+
+    def _on_block_from_scheme(self, sync_index: int) -> None:
+        self._select_block(sync_index, source="scheme")
+
+    def _select_block(self, sync_index: int, source: str) -> None:
+        row = self._table.row_for_sync_index(sync_index)
+        self._preview.show_block_details(row)
+        self._scheme.select_block(sync_index)
+
+        if source != "plot":
+            self._plots.highlight_block(sync_index)
+
+        if self._table_container.isVisible():
+            if source != "table":
+                self._table.select_by_sync_index(sync_index)
+        else:
+            # Remember for when the table is opened
+            self._pending_table_select = sync_index
+
+    # ================================================================== #
+    #  Status / log helpers                                                #
+    # ================================================================== #
+
+    def _set_seq_state(self, state: str, name: str = "",
+                       detail: str = "") -> None:
+        _state_map = _STATE_DARK if system_is_dark() else _STATE_LIGHT
+        color, icon = _state_map.get(state, ("#9e9e9e", "●"))
+        text = f"  {icon}  {name}" if name else f"  {icon}  No file selected"
+        if detail:
+            text += f"  —  {detail}"
+        self._seq_status.setStyleSheet(f"color: {color}; font-weight: bold;")
+        self._seq_status.setText(text)
+
+    def _on_hover(self, t_s: float, channel: str, value: float) -> None:
+        block = find_block_at_time(t_s, self._block_rows) if self._block_rows else None
+        blk = f"  block #{block.sync_index} [{block.block_type}]" if block else ""
+        self._status_lbl.setText(
+            f"t = {t_s * 1e6:.4f} µs   {channel} = {value:.4g}{blk}"
+        )
+
+    def _log(self, msg: str, error: bool = False) -> None:
+        self._preview.append_log(msg, error=error)
+        if error:
+            logging.error(msg)
+        else:
+            logging.info(msg)
+
+    def _start_busy(self, tip: str) -> None:
+        self._progress.setVisible(True)
+        self._status_lbl.setText(tip)
+        self._act_enabled(run=False, export=False)
+
+    def _stop_busy(self) -> None:
+        self._progress.setVisible(False)
+
+    def _act_enabled(self, run: bool, export: bool) -> None:
+        self._btn_run.setEnabled(run and bool(self._seq_path))
+        self._btn_export.setEnabled(export and self._seq_data is not None)

+ 519 - 0
services/seq-interp/src/gui/plot_panel.py

@@ -0,0 +1,519 @@
+"""
+Central plot panel — individual pg.PlotWidget rows on a shared X axis.
+
+Row visibility:
+  RF Magnitude   — always visible, no toggle
+  RF I/Q         — optional, hidden by default
+  Gx / Gy / Gz   — optional, hidden by default
+  Gate RF/ADC/TR — optional, hidden by default
+  Block dur.     — optional, hidden by default
+
+Toggle buttons in the header bar show/hide entire rows.
+Visibility state is preserved across plot_all() calls (e.g. after rerun).
+"""
+from __future__ import annotations
+
+from typing import List, Optional
+
+import numpy as np
+import pyqtgraph as pg
+from PySide6.QtCore import Signal, Qt, QPoint
+from PySide6.QtGui import QColor, QFont
+from PySide6.QtWidgets import (
+    QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QMenu,
+    QApplication, QFileDialog,
+)
+
+from seq_interp.src.gui.adapters import (
+    BlockRow, gate_to_step, build_block_rows, find_block_at_time,
+)
+from seq_interp.src.gui.scheme_panel import system_is_dark
+
+# Waveform / overlay colours (used for curve pens and region fills)
+_C = {
+    "rf_mag":   "#d63031",
+    "rf_real":  "#00b894",
+    "rf_imag":  "#0984e3",
+    "gx":       "#e17055",
+    "gy":       "#6c5ce7",
+    "gz":       "#00cec9",
+    "gate_rf":  "#d63031",
+    "gate_adc": "#0984e3",
+    "gate_tr":  "#6c5ce7",
+    "cl":       "#636e72",
+    "start_d":  "#fd79a8",
+    "rf_d":     "#fdcb6e",
+    "tr_d":     "#74b9ff",
+    "bound":    "#b2bec3",
+}
+
+# Toggle-button label colours — ≥4.5:1 contrast on each background.
+_C_TEXT_LIGHT = {
+    "rf_mag":   "#b71c1c",
+    "rf_real":  "#00695c",
+    "rf_imag":  "#01579b",
+    "gx":       "#bf360c",
+    "gy":       "#4527a0",
+    "gz":       "#006064",
+    "gate_rf":  "#b71c1c",
+    "gate_adc": "#01579b",
+    "gate_tr":  "#4527a0",
+    "cl":       "#37474f",
+}
+_C_TEXT_DARK = {
+    "rf_mag":   "#ef5350",
+    "rf_real":  "#26d4b0",
+    "rf_imag":  "#42a5f5",
+    "gx":       "#ff8a65",
+    "gy":       "#9575cd",
+    "gz":       "#26ceca",
+    "gate_rf":  "#ef5350",
+    "gate_adc": "#42a5f5",
+    "gate_tr":  "#9575cd",
+    "cl":       "#90a4ae",
+}
+
+_MAX_VLINES = 400
+
+
+class PlotPanel(QWidget):
+    """
+    All waveforms and sync gates on a shared time axis.
+    Emits blockClicked(sync_index) on left-click.
+    Emits timeHovered(t_s, channel, value) for status-bar updates.
+    """
+    blockClicked = Signal(int)
+    timeHovered  = Signal(float, str, float)
+
+    def __init__(self, parent: QWidget | None = None):
+        super().__init__(parent)
+
+        _dark = system_is_dark()
+        pg.setConfigOptions(
+            antialias=True,
+            background=QColor("#1e1e1e") if _dark else QColor("#ffffff"),
+            foreground="w" if _dark else "k",
+        )
+
+        root = QVBoxLayout(self)
+        root.setContentsMargins(0, 0, 0, 0)
+        root.setSpacing(0)
+
+        # ── toggle button bar ─────────────────────────────────────────────
+        self._btn_bar = QWidget()
+        self._btn_bar.setObjectName("PlotBtnBar")
+        self._btn_bar.setStyleSheet(
+            "#PlotBtnBar { border-bottom: 1px solid palette(mid); }"
+        )
+        self._btn_lay = QHBoxLayout(self._btn_bar)
+        self._btn_lay.setContentsMargins(6, 3, 6, 3)
+        self._btn_lay.setSpacing(4)
+        lbl = QLabel("Show:")
+        lbl.setFont(QFont("Arial", 9))
+        self._btn_lay.addWidget(lbl)   # index 0 — stays forever
+        self._btn_lay.addStretch()     # index 1 — stays forever; buttons inserted before it
+        root.addWidget(self._btn_bar)
+
+        # ── plot container ────────────────────────────────────────────────
+        self._plot_area = QWidget()
+        self._plot_area_lay = QVBoxLayout(self._plot_area)
+        self._plot_area_lay.setContentsMargins(0, 0, 0, 0)
+        self._plot_area_lay.setSpacing(1)
+        root.addWidget(self._plot_area, stretch=1)
+
+        # State
+        self._plot_widgets: dict[str, pg.PlotWidget] = {}
+        self._plots:        dict[str, pg.PlotItem]   = {}
+        self._curves:       dict[str, pg.PlotDataItem] = {}
+        self._row_btns:     dict[str, QPushButton]   = {}
+        self._block_rows:   List[BlockRow] = []
+        self._ref_plot:     Optional[pg.PlotItem] = None
+        # Remembered visibility (persists across plot_all calls).
+        # For simple rows the key is the row key; for combined rows (gradients,
+        # gates) the key is the individual curve key (gx, gy, gz, gate_rf, …).
+        self._visible: set[str] = set()
+        # Maps curve_key → row_key for curves that live in a combined row.
+        self._curve_to_row: dict[str, str] = {}
+        # Maps row_key → [curve_keys] for combined rows.
+        self._row_curves: dict[str, list[str]] = {}
+
+    # ------------------------------------------------------------------ #
+    #  Public API                                                          #
+    # ------------------------------------------------------------------ #
+
+    def plot_all(self, seq_data: dict, sync_data: dict) -> None:
+        self._clear()
+        self._block_rows = build_block_rows(seq_data, sync_data)
+
+        self._build_rf_rows(seq_data)
+        self._build_gradients_row(seq_data)
+        self._build_gates_row(sync_data)
+        self._build_cl_row(sync_data)
+
+        durs      = sync_data["blocks_duration"]
+        cumtimes  = np.cumsum([0.0] + list(durs))
+        self._draw_boundaries(cumtimes)
+        self._draw_delay_regions()
+        self._attach_mouse_events()
+
+        # Apply visibility.
+        # rf_mag is always on (fall back to first row if sequence has no RF).
+        always_key = "rf_mag" if "rf_mag" in self._plot_widgets else next(
+            iter(self._plot_widgets), None
+        )
+        for key, pw in self._plot_widgets.items():
+            if key == always_key:
+                pw.setVisible(True)
+            elif key in self._row_curves:
+                # Combined row: visible when ≥1 of its curves is on.
+                # Also restore per-curve visibility and button states.
+                curves = self._row_curves[key]
+                for c_key in curves:
+                    on = c_key in self._visible
+                    c = self._curves.get(c_key)
+                    if c:
+                        c.setVisible(on)
+                    if c_key in self._row_btns:
+                        self._row_btns[c_key].setChecked(on)
+                pw.setVisible(any(c in self._visible for c in curves))
+            else:
+                # Simple row: row key itself tracked in _visible.
+                on = key in self._visible
+                pw.setVisible(on)
+                if key in self._row_btns:
+                    self._row_btns[key].setChecked(on)
+
+    def highlight_block(self, sync_index: int) -> None:
+        for row in self._block_rows:
+            if row.sync_index == sync_index:
+                mid  = (row.t_start + row.t_end) / 2.0
+                span = max(row.t_end - row.t_start, 1e-6)
+                if self._ref_plot:
+                    self._ref_plot.setXRange(mid - span * 5, mid + span * 5)
+                break
+
+    def fit_all(self) -> None:
+        if self._ref_plot:
+            self._ref_plot.enableAutoRange(axis="x")
+            for p in self._plots.values():
+                p.enableAutoRange(axis="y")
+
+    # ------------------------------------------------------------------ #
+    #  Row builders                                                        #
+    # ------------------------------------------------------------------ #
+
+    def _build_rf_rows(self, seq_data: dict) -> None:
+        rf_raw = seq_data.get("rf")
+        t_rf   = seq_data.get("t_rf")
+        if rf_raw is None or t_rf is None or len(rf_raw) == 0:
+            return
+
+        rf  = np.asarray(rf_raw)
+        t   = np.asarray(t_rf, dtype=float)
+        mag = np.abs(rf)
+
+        # RF Magnitude — always on, no toggle button
+        p = self._add_row("rf_mag", "RF Mag", "a.u.", h=90)
+        self._curves["rf_mag"] = p.plot(
+            t, mag, pen=pg.mkPen(_C["rf_mag"], width=1.5), name="RF Mag"
+        )
+
+        # RF I/Q — optional
+        p2 = self._add_row("rf_iq", "RF I/Q", "a.u.", h=75)
+        self._curves["rf_real"] = p2.plot(
+            t, rf.real, pen=pg.mkPen(_C["rf_real"], width=1.2), name="RF Re"
+        )
+        self._curves["rf_imag"] = p2.plot(
+            t, rf.imag, pen=pg.mkPen(_C["rf_imag"], width=1.2), name="RF Im"
+        )
+        self._add_row_btn("rf_iq", "RF I/Q", "rf_real")
+
+    def _build_gradients_row(self, seq_data: dict) -> None:
+        axes = [("gx", "Gx"), ("gy", "Gy"), ("gz", "Gz")]
+        present = [
+            (key, lbl) for key, lbl in axes
+            if key in seq_data and len(seq_data.get(key, []))
+        ]
+        if not present:
+            return
+        curve_keys = [key for key, _ in present]
+        self._row_curves["gradients"] = curve_keys
+        for key in curve_keys:
+            self._curve_to_row[key] = "gradients"
+
+        p = self._add_row("gradients", "Gradients", "Hz/m", h=100)
+        p.addLegend(offset=(10, 5))
+        for key, label in present:
+            t = np.asarray(seq_data[f"t_{key}"], dtype=float)
+            v = np.asarray(seq_data[key],         dtype=float)
+            self._curves[key] = p.plot(
+                t, v, pen=pg.mkPen(_C[key], width=1.5), name=label
+            )
+            self._add_curve_btn(key, label, key)
+
+    def _build_gates_row(self, sync_data: dict) -> None:
+        durs  = sync_data["blocks_duration"]
+        gates = [
+            ("gate_rf",        "Gate RF",  "gate_rf"),
+            ("gate_adc",       "Gate ADC", "gate_adc"),
+            ("gate_tr_switch", "Gate TR",  "gate_tr"),
+        ]
+        curve_keys = [dk for dk, _, _ in gates]
+        self._row_curves["gates"] = curve_keys
+        for key in curve_keys:
+            self._curve_to_row[key] = "gates"
+
+        p = self._add_row("gates", "Gates", "", h=80)
+        p.setYRange(-0.15, 1.15)
+        p.addLegend(offset=(10, 5))
+        for data_key, label, color_key in gates:
+            t, v  = gate_to_step(sync_data[data_key], durs)
+            color = _C[color_key]
+            self._curves[data_key] = p.plot(
+                t, v,
+                pen=pg.mkPen(color, width=1.5),
+                fillLevel=0.0,
+                brush=pg.mkBrush(color + "40"),
+                name=label,
+            )
+            self._add_curve_btn(data_key, label, color_key)
+
+    def _build_cl_row(self, sync_data: dict) -> None:
+        durs     = sync_data["blocks_duration"]
+        cumtimes = np.cumsum([0.0] + list(durs))
+        t_pts, v_pts = [], []
+        for i, d in enumerate(durs):
+            t_pts += [cumtimes[i], cumtimes[i + 1]]
+            v_pts += [d * 1e6,     d * 1e6]
+        p = self._add_row("cl", "Block dur.", "µs", h=65)
+        self._curves["cl"] = p.plot(
+            np.array(t_pts), np.array(v_pts),
+            pen=pg.mkPen(_C["cl"], width=1.2),
+        )
+        self._add_row_btn("cl", "Dur", "cl")
+
+    # ------------------------------------------------------------------ #
+    #  Overlays                                                            #
+    # ------------------------------------------------------------------ #
+
+    def _draw_boundaries(self, cumtimes: np.ndarray) -> None:
+        n    = len(cumtimes)
+        step = max(1, n // _MAX_VLINES)
+        pen  = pg.mkPen(_C["bound"] + "80", width=0.5, style=Qt.DotLine)
+        for plot in self._plots.values():
+            for t in cumtimes[::step]:
+                plot.addItem(
+                    pg.InfiniteLine(pos=float(t), angle=90, pen=pen, movable=False)
+                )
+
+    def _draw_delay_regions(self) -> None:
+        color_map = {
+            "START": _C["start_d"] + "30",
+            "RF":    _C["rf_d"]    + "30",
+            "TR":    _C["tr_d"]    + "30",
+        }
+        for row in self._block_rows:
+            if not row.is_delay:
+                continue
+            color = color_map.get(row.delay_type, "#ffffff30")
+            for plot in self._plots.values():
+                region = pg.LinearRegionItem(
+                    values=[row.t_start, row.t_end],
+                    brush=pg.mkBrush(color),
+                    pen=pg.mkPen(None),
+                    movable=False,
+                )
+                region.setZValue(-10)
+                plot.addItem(region)
+
+    # ------------------------------------------------------------------ #
+    #  Mouse interactions                                                  #
+    # ------------------------------------------------------------------ #
+
+    def _attach_mouse_events(self) -> None:
+        for name, plot in self._plots.items():
+            plot.scene().sigMouseClicked.connect(
+                lambda ev, p=plot, n=name: self._on_click(ev, p, n)
+            )
+            plot.scene().sigMouseMoved.connect(
+                lambda pos, p=plot, n=name: self._on_hover(pos, p, n)
+            )
+
+    def _on_click(self, ev, plot: pg.PlotItem, channel: str) -> None:
+        if not plot.sceneBoundingRect().contains(ev.scenePos()):
+            return
+        if ev.button() == Qt.RightButton:
+            self._show_context_menu(ev, plot, channel)
+            ev.accept()
+            return
+        if ev.button() == Qt.LeftButton:
+            vb = plot.getViewBox()
+            pt = vb.mapSceneToView(ev.scenePos())
+            block = find_block_at_time(pt.x(), self._block_rows)
+            if block is not None:
+                self.blockClicked.emit(block.sync_index)
+            ev.accept()
+
+    def _on_hover(self, scene_pos, plot: pg.PlotItem, channel: str) -> None:
+        if not plot.sceneBoundingRect().contains(scene_pos):
+            return
+        vb = plot.getViewBox()
+        pt = vb.mapSceneToView(scene_pos)
+        self.timeHovered.emit(pt.x(), channel, pt.y())
+
+    def _show_context_menu(self, ev, plot: pg.PlotItem, channel: str) -> None:
+        vb    = plot.getViewBox()
+        t_val = vb.mapSceneToView(ev.scenePos()).x()
+
+        menu     = QMenu(self)
+        a_fit    = menu.addAction("Fit all")
+        a_fit.triggered.connect(self.fit_all)
+        a_reset  = menu.addAction("Reset zoom")
+        a_reset.triggered.connect(lambda: vb.autoRange())
+        menu.addSeparator()
+        a_grid = menu.addAction("Toggle grid")
+        a_grid.triggered.connect(
+            lambda: plot.showGrid(
+                x=not plot.ctrl.xGridCheck.isChecked(),
+                y=not plot.ctrl.yGridCheck.isChecked(),
+                alpha=0.3,
+            )
+        )
+        a_copy = menu.addAction(f"Copy time  {t_val * 1e6:.4f} µs")
+        a_copy.triggered.connect(
+            lambda: QApplication.clipboard().setText(f"{t_val * 1e6:.6f}")
+        )
+        block = find_block_at_time(t_val, self._block_rows)
+        if block is not None:
+            menu.addSeparator()
+            a_jump = menu.addAction(
+                f"Jump to block #{block.sync_index}  [{block.block_type}]"
+            )
+            a_jump.triggered.connect(lambda: self.blockClicked.emit(block.sync_index))
+        menu.addSeparator()
+        a_export = menu.addAction("Export plot as PNG…")
+        a_export.triggered.connect(lambda: self._export_plot(plot, channel))
+        sp = ev.screenPos()
+        menu.exec(QPoint(int(sp.x()), int(sp.y())))
+
+    def _export_plot(self, plot: pg.PlotItem, channel: str) -> None:
+        path, _ = QFileDialog.getSaveFileName(
+            self, f"Export {channel}", f"{channel}.png",
+            "PNG images (*.png);;All files (*)",
+        )
+        if not path:
+            return
+        pg.exporters.ImageExporter(plot).export(path)
+
+    # ------------------------------------------------------------------ #
+    #  Helpers                                                             #
+    # ------------------------------------------------------------------ #
+
+    def _add_row(self, key: str, label: str, units: str, h: int) -> pg.PlotItem:
+        pw = pg.PlotWidget()
+        pw.setMinimumHeight(h)
+        pw.setMaximumHeight(h + 40)
+        p = pw.getPlotItem()
+        p.setLabel("left", label, units=units)
+        p.showGrid(x=True, y=True, alpha=0.25)
+        self._plot_widgets[key] = pw
+        self._plots[key]        = p
+        self._plot_area_lay.addWidget(pw)
+        if self._ref_plot is None:
+            self._ref_plot = p
+        else:
+            p.setXLink(self._ref_plot)
+        return p
+
+    def _add_row_btn(self, key: str, label: str, color_key: str) -> None:
+        _text = _C_TEXT_DARK if system_is_dark() else _C_TEXT_LIGHT
+        color = _text.get(color_key, _C.get(color_key, "#555555"))
+        btn   = QPushButton(label)
+        btn.setCheckable(True)
+        btn.setChecked(key in self._visible)
+        btn.setFixedHeight(20)
+        btn.setStyleSheet(
+            f"QPushButton {{"
+            f"  color: palette(mid); font-size: 11px;"
+            f"  padding: 0px 7px;"
+            f"  border: 1px solid palette(mid);"
+            f"  border-radius: 3px;"
+            f"}}"
+            f"QPushButton:checked {{"
+            f"  color: {color}; font-weight: bold;"
+            f"  border-color: {color};"
+            f"  background: palette(alternateBase);"
+            f"}}"
+        )
+        btn.toggled.connect(lambda checked, k=key: self._on_row_toggle(k, checked))
+        # Insert before the trailing stretch (last item in _btn_lay)
+        self._btn_lay.insertWidget(self._btn_lay.count() - 1, btn)
+        self._row_btns[key] = btn
+
+    def _on_row_toggle(self, key: str, checked: bool) -> None:
+        if checked:
+            self._visible.add(key)
+        else:
+            self._visible.discard(key)
+        pw = self._plot_widgets.get(key)
+        if pw is not None:
+            pw.setVisible(checked)
+
+    def _add_curve_btn(self, curve_key: str, label: str, color_key: str) -> None:
+        """Toggle button for one curve inside a combined row."""
+        _text = _C_TEXT_DARK if system_is_dark() else _C_TEXT_LIGHT
+        color = _text.get(color_key, _C.get(color_key, "#555555"))
+        btn   = QPushButton(label)
+        btn.setCheckable(True)
+        btn.setChecked(curve_key in self._visible)
+        btn.setFixedHeight(20)
+        btn.setStyleSheet(
+            f"QPushButton {{"
+            f"  color: palette(mid); font-size: 11px;"
+            f"  padding: 0px 7px;"
+            f"  border: 1px solid palette(mid);"
+            f"  border-radius: 3px;"
+            f"}}"
+            f"QPushButton:checked {{"
+            f"  color: {color}; font-weight: bold;"
+            f"  border-color: {color};"
+            f"  background: palette(alternateBase);"
+            f"}}"
+        )
+        btn.toggled.connect(
+            lambda checked, k=curve_key: self._on_curve_toggle(k, checked)
+        )
+        self._btn_lay.insertWidget(self._btn_lay.count() - 1, btn)
+        self._row_btns[curve_key] = btn
+
+    def _on_curve_toggle(self, curve_key: str, checked: bool) -> None:
+        if checked:
+            self._visible.add(curve_key)
+        else:
+            self._visible.discard(curve_key)
+        c = self._curves.get(curve_key)
+        if c is not None:
+            c.setVisible(checked)
+        # Show the parent row when ≥1 of its curves is on; hide when all off.
+        row_key = self._curve_to_row.get(curve_key)
+        if row_key:
+            any_on = any(k in self._visible for k in self._row_curves.get(row_key, []))
+            pw = self._plot_widgets.get(row_key)
+            if pw is not None:
+                pw.setVisible(any_on)
+
+    def _clear(self) -> None:
+        for pw in self._plot_widgets.values():
+            self._plot_area_lay.removeWidget(pw)
+            pw.deleteLater()
+        self._plot_widgets.clear()
+        self._plots.clear()
+        self._curves.clear()
+        self._ref_plot = None
+        self._block_rows.clear()
+        for btn in self._row_btns.values():
+            self._btn_lay.removeWidget(btn)
+            btn.deleteLater()
+        self._row_btns.clear()
+        self._curve_to_row.clear()
+        self._row_curves.clear()

+ 145 - 0
services/seq-interp/src/gui/preview_panel.py

@@ -0,0 +1,145 @@
+"""
+Right-side panel: tabbed previews for Sync XML, POST JSON, block details,
+warnings, and live log output.
+"""
+from __future__ import annotations
+
+import json as _json
+from datetime import datetime
+
+from PySide6.QtWidgets import (
+    QApplication, QWidget, QVBoxLayout, QTabWidget, QTextEdit,
+)
+from PySide6.QtGui import QFont, QColor, QPalette, QTextCursor, QTextCharFormat
+
+from seq_interp.src.gui.adapters import BlockRow
+
+
+_MONO = QFont("Courier New", 9)
+
+# Tab indices (keep in sync with addTab order below)
+_TAB_DETAILS  = 0
+_TAB_WARNINGS = 1
+_TAB_XML      = 2
+_TAB_JSON     = 3
+_TAB_LOG      = 4
+
+
+class PreviewPanel(QWidget):
+    def __init__(self, parent: QWidget | None = None):
+        super().__init__(parent)
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+
+        self.tabs = QTabWidget()
+        layout.addWidget(self.tabs)
+
+        self._detail_edit = self._make_edit(
+            "Click a block in the scheme, plot or table to see details…"
+        )
+        self.tabs.addTab(self._detail_edit, "Block Details")   # 0
+
+        self._warn_edit = self._make_edit("No warnings yet.")
+        self.tabs.addTab(self._warn_edit, "Warnings")           # 1
+
+        self._xml_edit = self._make_edit(
+            "Run interpretation to populate XML preview…"
+        )
+        self.tabs.addTab(self._xml_edit, "Sync XML")            # 2
+
+        self._json_edit = self._make_edit(
+            "Export artifacts to populate POST JSON preview…"
+        )
+        self.tabs.addTab(self._json_edit, "POST JSON")          # 3
+
+        self._log_edit = self._make_edit("")
+        self._log_edit.setLineWrapMode(QTextEdit.WidgetWidth)
+        self.tabs.addTab(self._log_edit, "Log")                 # 4
+
+    # ── block details ─────────────────────────────────────────────────────────
+
+    def show_block_details(self, row: BlockRow | None) -> None:
+        if row is None:
+            self._detail_edit.clear()
+            return
+        lines = [
+            f"Sync index    : {row.sync_index}",
+            f"Orig index    : {row.orig_index if row.orig_index >= 0 else '— (inserted)'}",
+            f"Type          : {row.block_type}",
+            f"Duration      : {row.duration * 1e6:.4f} µs",
+            f"T start       : {row.t_start * 1e6:.4f} µs",
+            f"T end         : {row.t_end * 1e6:.4f} µs",
+            f"Is delay      : {row.is_delay}  ({row.delay_type or '—'})",
+            "─" * 32,
+            f"Gate RF       : {row.gate_rf}",
+            f"Gate ADC      : {row.gate_adc}",
+            f"Gate TR       : {row.gate_tr}",
+            "─" * 32,
+            f"Has RF        : {row.has_rf}",
+            f"Has ADC       : {row.has_adc}",
+            f"Has Grad      : {row.has_grad}",
+        ]
+        self._detail_edit.setPlainText("\n".join(lines))
+        self.tabs.setCurrentIndex(_TAB_DETAILS)
+
+    # ── warnings ──────────────────────────────────────────────────────────────
+
+    def set_warnings(self, warnings: list[str]) -> None:
+        if warnings:
+            self._warn_edit.setPlainText("\n".join(f"⚠  {w}" for w in warnings))
+            self.tabs.setTabText(_TAB_WARNINGS, f"Warnings ({len(warnings)})")
+        else:
+            self._warn_edit.setPlainText("No warnings.")
+            self.tabs.setTabText(_TAB_WARNINGS, "Warnings")
+
+    def add_error(self, msg: str) -> None:
+        current = self._warn_edit.toPlainText()
+        self._warn_edit.setPlainText(
+            (current + "\n" if current else "") + f"✗  {msg}"
+        )
+        n = self._warn_edit.toPlainText().count("\n") + 1
+        self.tabs.setTabText(_TAB_WARNINGS, f"Warnings ({n})")
+        self.tabs.setCurrentIndex(_TAB_WARNINGS)
+
+    # ── xml / json previews ───────────────────────────────────────────────────
+
+    def set_xml_text(self, text: str) -> None:
+        self._xml_edit.setPlainText(text)
+
+    def set_post_json(self, data: dict) -> None:
+        self._json_edit.setPlainText(_json.dumps(data, indent=2, default=str))
+
+    def set_post_json_text(self, text: str) -> None:
+        self._json_edit.setPlainText(text)
+
+    # ── log ───────────────────────────────────────────────────────────────────
+
+    def append_log(self, msg: str, error: bool = False) -> None:
+        ts   = datetime.now().strftime("%H:%M:%S")
+        line = f"[{ts}] {msg}"
+        cursor = self._log_edit.textCursor()
+        cursor.movePosition(QTextCursor.End)
+        fmt = QTextCharFormat()
+        if error:
+            fmt.setForeground(QColor("#ef5350"))
+        else:
+            fmt.setForeground(QApplication.palette().color(QPalette.Text))
+        cursor.setCharFormat(fmt)
+        cursor.insertText(line + "\n")
+        self._log_edit.setTextCursor(cursor)
+        self._log_edit.ensureCursorVisible()
+
+    def clear_log(self) -> None:
+        self._log_edit.clear()
+
+    # ── helpers ───────────────────────────────────────────────────────────────
+
+    @staticmethod
+    def _make_edit(placeholder: str = "") -> QTextEdit:
+        edit = QTextEdit()
+        edit.setReadOnly(True)
+        edit.setFont(_MONO)
+        edit.setLineWrapMode(QTextEdit.NoWrap)
+        if placeholder:
+            edit.setPlaceholderText(placeholder)
+        return edit

+ 294 - 0
services/seq-interp/src/gui/scheme_panel.py

@@ -0,0 +1,294 @@
+"""
+Compact horizontal block-type timeline shown after sequence loading.
+
+Each sync block is rendered as a proportional coloured segment.
+Background, text and chrome colours are derived from the OS palette at paint
+time so the widget looks correct in both light and dark OS themes.
+Click or hover to inspect; emits blockClicked(sync_index).
+"""
+from __future__ import annotations
+
+import sys
+from typing import List, Optional
+
+from PySide6.QtCore import Signal, Qt, QRect
+from PySide6.QtGui import (
+    QPainter, QColor, QPen, QFont, QFontMetrics, QPalette,
+)
+from PySide6.QtWidgets import QWidget, QToolTip, QSizePolicy, QApplication
+
+from seq_interp.src.gui.adapters import BlockRow
+
+
+def system_is_dark() -> bool:
+    """Return True when the OS is running in dark mode.
+
+    On Windows the registry is authoritative — Qt's widget-area palette may
+    stay light even when the title bar is dark (Windows styles them separately).
+    Other platforms fall back to QPalette.Window lightness.
+    """
+    if sys.platform == "win32":
+        try:
+            import winreg
+            key = winreg.OpenKey(
+                winreg.HKEY_CURRENT_USER,
+                r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
+            )
+            val, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
+            winreg.CloseKey(key)
+            return val == 0          # 0 = dark, 1 = light
+        except Exception:
+            pass
+    return QApplication.palette().color(QPalette.Window).lightness() < 128
+
+# ── block colour map (semantic, theme-neutral vivid hues) ────────────────────
+
+def _row_color(row: BlockRow) -> QColor:
+    if row.is_delay:
+        return QColor({
+            "START": "#f48fb1",   # pink
+            "RF":    "#ffcc80",   # amber
+            "TR":    "#90caf9",   # light-blue
+        }.get(row.delay_type, "#bdbdbd"))
+    types = set(row.block_type.split(", "))
+    if "RF" in types and "ADC" in types:
+        return QColor("#ffb74d")   # orange  — mixed
+    if "RF" in types:
+        return QColor("#e53935")   # red
+    if "ADC" in types:
+        return QColor("#43a047")   # green
+    if "GRAD" in types:
+        return QColor("#8e24aa")   # purple
+    return QColor("#bdbdbd")       # gray    — plain / empty
+
+
+_LEGEND = [
+    ("#f48fb1", "Start dly"),
+    ("#ffcc80", "RF dly"),
+    ("#90caf9", "TR dly"),
+    ("#e53935", "RF"),
+    ("#43a047", "ADC"),
+    ("#8e24aa", "Grad"),
+    ("#ffb74d", "RF+ADC"),
+    ("#bdbdbd", "Delay"),
+]
+
+# ── geometry constants ────────────────────────────────────────────────────────
+
+_LEGEND_H = 16   # legend swatch row height (px)
+_BAR_H    = 28   # coloured block bar height (px)
+_TICK_H   = 18   # time-tick label row height (px)
+_TOTAL_H  = _LEGEND_H + _BAR_H + _TICK_H   # 62 px total
+
+_TICK_FONT   = QFont("Arial", 7)
+_LEGEND_FONT = QFont("Arial", 7)
+
+
+class SchemePanel(QWidget):
+    """
+    Proportional block-type timeline.  Adapts to the OS light/dark palette —
+    background, text and chrome colours are derived from QPalette at paint time.
+    Emits blockClicked(sync_index) on left-click.
+    """
+    blockClicked = Signal(int)
+
+    def __init__(self, parent: QWidget | None = None):
+        super().__init__(parent)
+        # WA_OpaquePaintEvent: Qt won't pre-clear background before paintEvent.
+        # WA_NoSystemBackground: OS won't paint its own background colour.
+        # Both are still needed — we fill explicitly from the palette instead of
+        # a hardcoded white, and these attributes prevent any flicker or bleed.
+        self.setAttribute(Qt.WA_OpaquePaintEvent, True)
+        self.setAttribute(Qt.WA_NoSystemBackground, True)
+        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+        self.setFixedHeight(_TOTAL_H)
+        self.setMouseTracking(True)
+        self._rows: List[BlockRow] = []
+        self._total_dur: float = 0.0
+        self._selected: int = -1
+        self._hovered: int = -1
+
+    # ── public ───────────────────────────────────────────────────────────────
+
+    def load_rows(self, rows: List[BlockRow]) -> None:
+        self._rows = rows
+        self._total_dur = rows[-1].t_end if rows else 0.0
+        self._hovered = -1
+        self.update()
+
+    def select_block(self, sync_index: int) -> None:
+        if self._selected != sync_index:
+            self._selected = sync_index
+            self.update()
+
+    def clear(self) -> None:
+        self._rows = []
+        self._total_dur = 0.0
+        self._selected = -1
+        self._hovered = -1
+        self.update()
+
+    # ── palette helper ───────────────────────────────────────────────────────
+
+    def _theme_colors(self) -> dict:
+        """Return explicit paint-time colours for the current OS theme.
+
+        Explicit hex values are used instead of QPalette roles because on
+        Windows 11 the widget-area palette may not reflect dark mode even
+        when the title bar is dark (see system_is_dark()).
+        """
+        if system_is_dark():
+            return {
+                "bg":          QColor("#1e1e1e"),
+                "text":        QColor("#e4e4e4"),
+                "tick":        QColor("#888888"),
+                "border":      QColor("#454545"),
+                "placeholder": QColor("#606060"),
+                "ph_bg":       QColor("#262626"),
+                "sel_border":  QColor("#e4e4e4"),
+            }
+        return {
+            "bg":          QColor("#ffffff"),
+            "text":        QColor("#333333"),
+            "tick":        QColor("#555555"),
+            "border":      QColor("#cccccc"),
+            "placeholder": QColor("#9e9e9e"),
+            "ph_bg":       QColor("#f5f5f5"),
+            "sel_border":  QColor("#000000"),
+        }
+
+    # ── painting ─────────────────────────────────────────────────────────────
+
+    def paintEvent(self, _event) -> None:
+        p = QPainter(self)
+        p.setRenderHint(QPainter.Antialiasing, False)
+
+        tc = self._theme_colors()
+        # First draw call: fill our own background from the OS palette so
+        # no OS-theme bleed can appear in gaps between painted rectangles.
+        p.fillRect(self.rect(), tc["bg"])
+
+        if not self._rows or self._total_dur == 0:
+            self._paint_placeholder(p, tc)
+        else:
+            self._paint_legend(p, tc)
+            self._paint_blocks(p, tc)
+            self._paint_ticks(p, tc)
+
+        p.end()
+
+    def _paint_placeholder(self, p: QPainter, tc: dict) -> None:
+        p.fillRect(self.rect(), tc["ph_bg"])
+        p.setPen(tc["placeholder"])
+        p.setFont(QFont("Arial", 9))
+        p.drawText(self.rect(), Qt.AlignCenter,
+                   "Load and run a .seq file to see the sequence scheme")
+
+    def _paint_legend(self, p: QPainter, tc: dict) -> None:
+        p.setFont(_LEGEND_FONT)
+        fm = QFontMetrics(_LEGEND_FONT)
+        x = 4
+        swatch_w = 12
+        swatch_h = _LEGEND_H - 4
+        swatch_y = 2
+        for color_hex, label in _LEGEND:
+            # coloured swatch with thin border
+            p.fillRect(x, swatch_y, swatch_w, swatch_h, QColor(color_hex))
+            p.setPen(QPen(tc["border"], 1))
+            p.drawRect(x, swatch_y, swatch_w - 1, swatch_h - 1)
+            x += swatch_w + 2
+            # label text
+            text_w = fm.horizontalAdvance(label) + 4
+            p.setPen(tc["text"])
+            p.drawText(x, 0, text_w, _LEGEND_H, Qt.AlignVCenter | Qt.AlignLeft, label)
+            x += text_w + 6
+
+    def _paint_blocks(self, p: QPainter, tc: dict) -> None:
+        w     = self.width()
+        y0    = _LEGEND_H
+        bar_h = _BAR_H
+        scale = w / self._total_dur
+
+        # Subsample dense sequences so we never draw more than 3000 rects
+        rows = self._rows
+        if len(rows) > 3000:
+            step = len(rows) // 3000 + 1
+            rows = rows[::step]
+
+        for row in rows:
+            x  = int(row.t_start * scale)
+            bw = max(1, int((row.t_end - row.t_start) * scale))
+            color = _row_color(row)
+
+            if row.sync_index == self._selected:
+                p.fillRect(x, y0, bw, bar_h, color.lighter(150))
+                p.setPen(QPen(tc["sel_border"], 1))
+                p.drawRect(x, y0, max(0, bw - 1), bar_h - 1)
+            elif row.sync_index == self._hovered:
+                p.fillRect(x, y0, bw, bar_h, color.lighter(125))
+            else:
+                p.fillRect(x, y0, bw, bar_h, color)
+
+        # bottom border line
+        p.setPen(QPen(tc["border"], 1))
+        p.drawLine(0, y0 + bar_h, w, y0 + bar_h)
+
+    def _paint_ticks(self, p: QPainter, tc: dict) -> None:
+        w       = self.width()
+        tick_y  = _LEGEND_H + _BAR_H      # top of tick row
+        label_y = tick_y + 4
+        scale   = w / self._total_dur
+        p.setFont(_TICK_FONT)
+        p.setPen(tc["tick"])
+        n_ticks = max(2, w // 90)
+        for i in range(n_ticks + 1):
+            t      = self._total_dur * i / n_ticks
+            x_tick = int(t * scale)
+            p.drawLine(x_tick, tick_y, x_tick, tick_y + 3)
+            label = (f"{t * 1e3:.2f}ms" if self._total_dur >= 1e-3
+                     else f"{t * 1e6:.0f}µs")
+            p.drawText(x_tick + 2, label_y, 70, _TICK_H - 4,
+                       Qt.AlignLeft | Qt.AlignTop, label)
+
+    # ── mouse ────────────────────────────────────────────────────────────────
+
+    def mousePressEvent(self, event) -> None:
+        row = self._row_at(event.position().x())
+        if row is not None:
+            self._selected = row.sync_index
+            self.update()
+            self.blockClicked.emit(row.sync_index)
+
+    def mouseMoveEvent(self, event) -> None:
+        row = self._row_at(event.position().x())
+        new_h = row.sync_index if row else -1
+        if new_h != self._hovered:
+            self._hovered = new_h
+            self.update()
+        if row is not None:
+            tip = (
+                f"Block #{row.sync_index}  [{row.block_type}]\n"
+                f"t = {row.t_start * 1e6:.2f} – {row.t_end * 1e6:.2f} µs  "
+                f"({(row.t_end - row.t_start) * 1e6:.2f} µs)"
+            )
+            if row.is_delay:
+                tip += f"\n↳ Inserted {row.delay_type} delay"
+            QToolTip.showText(event.globalPosition().toPoint(), tip, self)
+        else:
+            QToolTip.hideText()
+
+    def leaveEvent(self, _event) -> None:
+        if self._hovered != -1:
+            self._hovered = -1
+            self.update()
+
+    # ── helper ───────────────────────────────────────────────────────────────
+
+    def _row_at(self, x_px: float) -> Optional[BlockRow]:
+        if not self._rows or self._total_dur == 0 or self.width() == 0:
+            return None
+        t = x_px / self.width() * self._total_dur
+        for row in self._rows:
+            if row.t_start <= t < row.t_end:
+                return row
+        return None

+ 228 - 0
services/seq-interp/src/gui/workers.py

@@ -0,0 +1,228 @@
+"""
+QThread workers that run heavy operations off the UI thread.
+"""
+from __future__ import annotations
+
+import os
+import tempfile
+
+from PySide6.QtCore import QThread, Signal
+
+from seq_interp.src.hardware.constraints import HardwareConstraints
+from seq_interp.src.interfaces.pulseq_adapter import PulseqLoader
+from seq_interp.src.core.synchronizer import Synchronizer
+from seq_interp.src.interfaces.xml_generator import XMLGenerator
+from seq_interp.src.interfaces.rf_exporter import RFExporter
+from seq_interp.src.interfaces.gradient_exporter import GradientExporter
+from seq_interp.src.interfaces.picoscope_exporter import PicoScopeExporter
+
+
+class LoadInterpWorker(QThread):
+    """
+    Load a .seq file and run the synchronizer in one shot.
+    Emits finished(seq_data, sync_data, hw) or error(msg).
+    """
+    finished = Signal(object, object, object)
+    error = Signal(str)
+    log_msg = Signal(str)
+
+    def __init__(self, seq_path: str, hw_config_path: str | None = None,
+                 hw_overrides: dict | None = None):
+        super().__init__()
+        self.seq_path = seq_path
+        self.hw_config_path = hw_config_path
+        self.hw_overrides = hw_overrides or {}
+
+    def run(self):
+        try:
+            hw = HardwareConstraints(json_path=self.hw_config_path)
+            for k, v in self.hw_overrides.items():
+                setattr(hw, k, v)
+
+            self.log_msg.emit(f"Loading: {self.seq_path}")
+            loader = PulseqLoader(hw)
+            seq_data = loader.load(self.seq_path)
+            n = len(seq_data.get("blocks", []))
+            self.log_msg.emit(f"Loaded {n} blocks (zero-duration removed)")
+
+            self.log_msg.emit("Running synchronizer…")
+            sync = Synchronizer(hw)
+            sync_data = sync.process(seq_data["sequence"])
+            nb = sync_data["number_of_blocks"]
+            self.log_msg.emit(f"Sync done: {nb} sync blocks (incl. inserted delays)")
+
+            self.finished.emit(seq_data, sync_data, hw)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+class SyncOnlyWorker(QThread):
+    """
+    Re-run just the synchronizer on an already-loaded sequence,
+    applying new hw_overrides from the GUI controls.
+    """
+    finished = Signal(object, object)   # sync_data, hw
+    error = Signal(str)
+    log_msg = Signal(str)
+
+    def __init__(self, seq_data: dict, hw_config_path: str | None,
+                 hw_overrides: dict | None = None):
+        super().__init__()
+        self.seq_data = seq_data
+        self.hw_config_path = hw_config_path
+        self.hw_overrides = hw_overrides or {}
+
+    def run(self):
+        try:
+            hw = HardwareConstraints(json_path=self.hw_config_path)
+            for k, v in self.hw_overrides.items():
+                setattr(hw, k, v)
+
+            self.log_msg.emit("Re-running synchronizer with updated settings…")
+            sync = Synchronizer(hw)
+            sync_data = sync.process(self.seq_data["sequence"])
+            nb = sync_data["number_of_blocks"]
+            self.log_msg.emit(f"Sync done: {nb} sync blocks")
+
+            self.finished.emit(sync_data, hw)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+class ExportWorker(QThread):
+    """
+    Export all artifacts to output_dir using the existing exporter classes.
+    Also generates sync XML in memory and returns it as a string for the preview panel.
+    """
+    finished = Signal(str, str, str)   # output_dir, xml_text, post_json_text
+    error = Signal(str)
+    log_msg = Signal(str)
+
+    def __init__(self, seq_data: dict, sync_data: dict, hw,
+                 output_dir: str):
+        super().__init__()
+        self.seq_data = seq_data
+        self.sync_data = sync_data
+        self.hw = hw
+        self.output_dir = output_dir
+
+    def run(self):
+        import json as _json
+        try:
+            os.makedirs(self.output_dir, exist_ok=True)
+
+            xml_path = os.path.join(self.output_dir, "sync_v2.xml")
+            self.log_msg.emit("Generating sync_v2.xml…")
+            xml_gen = XMLGenerator()
+            adc_values, adc_starts = xml_gen.generate(
+                self.sync_data, xml_path, self.hw
+            )
+            with open(xml_path, "r", encoding="utf-8") as fh:
+                xml_text = fh.read()
+
+            self.log_msg.emit("Exporting RF…")
+            RFExporter().export(
+                self.seq_data,
+                self.seq_data.get("params", {}),
+                self.output_dir,
+            )
+
+            if all(k in self.seq_data
+                   for k in ["gx", "gy", "gz", "t_gx", "t_gy", "t_gz"]):
+                self.log_msg.emit("Exporting gradients…")
+                GradientExporter().export(
+                    self.seq_data,
+                    self.seq_data.get("params", {}),
+                    self.output_dir,
+                )
+
+            self.log_msg.emit("Exporting PicoScope params…")
+            PicoScopeExporter().generate(
+                adc_values, adc_starts, self.output_dir, self.hw
+            )
+
+            # Build a minimal POST manifest for preview (no network call)
+            import datetime
+            post_payload = {
+                "info": {
+                    "infostr": os.path.basename(
+                        self.seq_data.get("params", {}).get("seq_path", "sequence")
+                    ),
+                    "engine": "DefaultEngine",
+                    "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
+                    "isync": {"file": "sync_v2.xml"},
+                    "isdr": {
+                        "file": f"rf_{self.seq_data.get('params', {}).get('rf_raster_time', self.hw.rf_raster_time)}_raster.bin"
+                    },
+                    "iadc": {
+                        "sample_freq": self.hw.adc_raster_time and (1 / self.hw.adc_raster_time),
+                        "points": adc_values,
+                        "times": adc_starts,
+                    },
+                    "igrax": {
+                        "file": "gx.txt",
+                        "enabled": "gx" in self.seq_data and len(self.seq_data["gx"]) > 0,
+                    },
+                    "igray": {
+                        "file": "gy.txt",
+                        "enabled": "gy" in self.seq_data and len(self.seq_data["gy"]) > 0,
+                    },
+                    "igraz": {
+                        "file": "gz.txt",
+                        "enabled": "gz" in self.seq_data and len(self.seq_data["gz"]) > 0,
+                    },
+                }
+            }
+            post_text = _json.dumps(post_payload, indent=2, default=str)
+
+            self.log_msg.emit(f"Export complete → {self.output_dir}")
+            self.finished.emit(self.output_dir, xml_text, post_text)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")
+
+
+class XmlPreviewWorker(QThread):
+    """
+    Generate sync XML in a temp file and return its text without writing to output_dir.
+    Used to populate the XML preview panel after interpretation, before export.
+    """
+    finished = Signal(str, str)   # xml_text, post_json_text (minimal)
+    error = Signal(str)
+
+    def __init__(self, sync_data: dict, hw):
+        super().__init__()
+        self.sync_data = sync_data
+        self.hw = hw
+
+    def run(self):
+        import json as _json, datetime
+        try:
+            with tempfile.NamedTemporaryFile(suffix=".xml", delete=False,
+                                             mode="w", encoding="utf-8") as tf:
+                tmp_path = tf.name
+            try:
+                xml_gen = XMLGenerator()
+                adc_values, adc_starts = xml_gen.generate(
+                    self.sync_data, tmp_path, self.hw
+                )
+                with open(tmp_path, "r", encoding="utf-8") as fh:
+                    xml_text = fh.read()
+            finally:
+                try:
+                    os.unlink(tmp_path)
+                except OSError:
+                    pass
+
+            post_payload = {
+                "note": "Preview only — run Export to write actual files",
+                "isync_blocks": self.sync_data.get("number_of_blocks"),
+                "adc_windows": len(adc_values),
+                "adc_durations_s": [float(v) for v in adc_values],
+                "adc_starts_s": [float(v) for v in adc_starts],
+                "time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
+            }
+            post_text = _json.dumps(post_payload, indent=2)
+
+            self.finished.emit(xml_text, post_text)
+        except Exception as exc:
+            self.error.emit(f"{type(exc).__name__}: {exc}")

+ 0 - 0
services/seq-interp/src/hardware/__init__.py


+ 65 - 0
services/seq-interp/src/hardware/constraints.py

@@ -0,0 +1,65 @@
+import json
+
+
+class HardwareConstraints:
+    """
+    Класс, хранящий аппаратные ограничения и настройки MRI-системы.
+    Может инициализироваться из JSON-файла.
+    """
+
+    def __init__(self, json_path: str = None):
+        # Значения по умолчанию (совместимые с pypulseq)
+        self.rf_raster_time = 1e-6  # сек, шаг временной дискретизации RF
+        self.grad_raster_time = 10e-6  # сек, шаг дискретизации градиента
+        self.adc_raster_time = 100e-9  # сек, шаг дискретизации АЦП
+        self.block_duration_raster = 10e-6  # сек, шаг дискретизации длительности блока
+
+        # Системные ограничения (по умолчанию отсутствуют задержки мертвого времени)
+        self.rf_dead_time = 0.0
+        self.rf_ringdown_time = 0.0
+        self.adc_dead_time = 0.0
+        self.gamma = 42.576e6  # Гиромагнитное отношение (Гц/Т) для протона
+
+        # Кастомные параметры системы
+        self.TR_DELAY = 20e-9  # сек, задержка после съема (между TR)
+        self.RF_DELAY = 500e-6  # сек, задержка перед RF-импульсом
+        self.START_DELAY = 17e-6  # сек, начальная задержка перед последовательностью
+        self.MIN_BLOCK_DURATION = 20e-9  # сек, минимальная длительность блока (квант времени последовательности)
+        self.GRAD_DELAY = 1000e-9
+
+        # Максимальные амплитуды
+        self.RF_MAX = 1.0  # относительная макс. амплитуда RF (нормирована на 1.0)
+        self.GRAD_MAX = 9e-3 * self.gamma  # макс. градиент (Гц/м) по умолчанию 9 mT/m * gamma
+
+        # Загрузка параметров из JSON при указании пути
+        if json_path:
+            self.load_from_json(json_path)
+
+    def load_from_json(self, json_path: str):
+        """
+        Загружает параметры аппаратных ограничений из JSON-файла.
+        """
+        with open(json_path, 'r') as f:
+            data = json.load(f)
+        # Обновление обязательных параметров (если указаны в файле)
+        self.rf_raster_time = data.get("rf_raster_time", self.rf_raster_time)
+        self.grad_raster_time = data.get("grad_raster_time", self.grad_raster_time)
+        self.adc_raster_time = data.get("adc_raster_time", self.adc_raster_time)
+        self.block_duration_raster = data.get("block_duration_raster", self.block_duration_raster)
+        self.rf_dead_time = data.get("rf_dead_time", self.rf_dead_time)
+        self.rf_ringdown_time = data.get("rf_ringdown_time", self.rf_ringdown_time)
+        self.adc_dead_time = data.get("adc_dead_time", self.adc_dead_time)
+        self.gamma = data.get("gamma", self.gamma)
+        # Обновление пользовательских параметров
+        self.TR_DELAY = data.get("TR_DELAY", self.TR_DELAY)
+        self.RF_DELAY = data.get("RF_DELAY", self.RF_DELAY)
+        if self.rf_raster_time == 0.5e-6:
+            self.START_DELAY = 885 * self.MIN_BLOCK_DURATION
+        elif self.rf_raster_time == 0.05e-6:
+            self.START_DELAY = 89 * self.MIN_BLOCK_DURATION
+        else:
+            self.START_DELAY = self.MIN_BLOCK_DURATION * 10
+        self.MIN_BLOCK_DURATION = data.get("MIN_BLOCK_DURATION", self.MIN_BLOCK_DURATION)
+        # Обновление максимальных амплитуд (если указаны)
+        self.RF_MAX = data.get("RF_MAX", self.RF_MAX)
+        self.GRAD_MAX = data.get("GRAD_MAX", self.GRAD_MAX)

+ 0 - 0
services/seq-interp/src/interfaces/__init__.py


+ 44 - 0
services/seq-interp/src/interfaces/gradient_exporter.py

@@ -0,0 +1,44 @@
+import numpy as np
+
+
+class GradientExporter:
+    """
+    Экспорт градиентных сигналов в формате test1_full/srv_interp.py
+    """
+
+    @staticmethod
+    def _duplicates_delete(loc_list):
+        new_list = [[0] * 2]
+        for i in range(len(loc_list)):
+            if loc_list[i][0] not in np.transpose(new_list)[0]:
+                new_list.append(loc_list[i])
+        return new_list
+
+    @staticmethod
+    def _gradient_time_convertation(params: dict, time_sample):
+        g_raster_time = params["grad_raster_time"]
+        return time_sample / g_raster_time
+
+    @staticmethod
+    def _gradient_ampl_convertation(params: dict, gradient_herz):
+        amplitude_max = params["G_amp_max"]
+        amplitude_raster = 32767
+        step_hz_m = amplitude_max / amplitude_raster
+        return gradient_herz / step_hz_m * 1000
+
+    def export(self, waveforms: dict, params: dict, path: str) -> None:
+        loc_t_gx = self._gradient_time_convertation(params, waveforms["t_gx"])
+        loc_t_gy = self._gradient_time_convertation(params, waveforms["t_gy"])
+        loc_t_gz = self._gradient_time_convertation(params, waveforms["t_gz"])
+
+        loc_gx = self._gradient_ampl_convertation(params, waveforms["gx"])
+        loc_gy = self._gradient_ampl_convertation(params, waveforms["gy"])
+        loc_gz = self._gradient_ampl_convertation(params, waveforms["gz"])
+
+        gx_out = self._duplicates_delete(np.transpose([loc_t_gx, loc_gx]))
+        gy_out = self._duplicates_delete(np.transpose([loc_t_gy, loc_gy]))
+        gz_out = self._duplicates_delete(np.transpose([loc_t_gz, loc_gz]))
+
+        np.savetxt(f"{path}/gx.txt", gx_out, fmt="%10.0f")
+        np.savetxt(f"{path}/gy.txt", gy_out, fmt="%10.0f")
+        np.savetxt(f"{path}/gz.txt", gz_out, fmt="%10.0f")

+ 105 - 0
services/seq-interp/src/interfaces/nnet_exporter.py

@@ -0,0 +1,105 @@
+import numpy as np
+
+
+class NNetExporter:
+    """
+    Генератор конфигурационного файла для PicoScope.
+    Создает XML с параметрами точек съема ADC для PicoScope.
+    """
+    def export_to_nn(self, seq) :
+        grad_signal = [
+            np.empty([0]),
+            np.empty([0]),
+            np.empty([0]),
+        ]
+        grad_time = [
+            np.empty([0]),
+            np.empty([0]),
+            np.empty([0]),
+        ]
+        rf_signal = np.empty([0])
+        rf_time = np.empty([0])
+        time_disp: str = "s"
+        valid_time_units = ["s", "ms", "us"]
+        t_factor_list = [1, 1e3, 1e6]
+        t_factor = t_factor_list[valid_time_units.index(time_disp)]
+        valid_grad_units = ["kHz/m", "mT/m"]
+        grad_disp: str = "kHz/m"
+        g_factor_list = [1e-3, 1e3 / seq.system.gamma]
+        g_factor = g_factor_list[valid_grad_units.index(grad_disp)]
+        t0 = 0
+        time_range = (0, np.inf)
+        current_sequence = seq
+        for block_counter in current_sequence.block_events:
+            block = current_sequence.get_block(block_counter)
+            is_valid = (time_range[0] <= t0 + seq.block_durations[block_counter]
+                        and t0 <= time_range[1])
+            grad_channels = ["gx", "gy", "gz"]
+            if is_valid:
+                for x in range(len(grad_channels)):  # Gradients
+                    if getattr(block, grad_channels[x], None) is not None:
+                        grad = getattr(block, grad_channels[x])
+                        if grad.type == "grad":
+                            # We extend the shape by adding the first and the last points in an effort of making the
+                            # display a bit less confusing...
+                            time = grad.delay + np.array([0, *grad.tt, grad.shape_dur])
+                            waveform = g_factor * np.array(
+                                (grad.first, *grad.waveform, grad.last)
+                            )
+                        else:
+                            time = np.array(cumsum(
+                                0,
+                                grad.delay,
+                                grad.rise_time,
+                                grad.flat_time,
+                                grad.fall_time,
+                            ))
+                            waveform = (
+                                    g_factor * grad.amplitude * np.array([0, 0, 1, 1, 0])
+                            )
+
+                        grad_time[x] = np.concatenate((grad_time[x], t_factor * (t0 + time)), axis=None)
+                        grad_signal[x] = np.concatenate((grad_signal[x], waveform), axis=None)
+
+                        t0 += seq.block_durations[block_counter]
+                if getattr(block, "rf", None) is not None:  # RF
+                    rf = block.rf
+                    tc, ic = pp.calc_rf_center(rf)
+                    time = rf.t
+                    signal = rf.signal
+                    if abs(signal[0]) != 0:
+                        signal = np.concatenate(([0], signal))
+                        time = np.concatenate(([time[0]], time))
+                        ic += 1
+
+                    if abs(signal[-1]) != 0:
+                        signal = np.concatenate((signal, [0]))
+                        time = np.concatenate((time, [time[-1]]))
+
+                    rf_signal = np.concatenate((rf_signal, np.abs(signal)), axis=None)
+                    rf_time = np.concatenate((rf_time, t_factor * (t0 + time + rf.delay)), axis=None)
+
+        # plt.plot(grad_time[0][0:50], grad_signal[0][0:50])
+        # plt.plot(grad_time[1][0:50], grad_signal[1][0:50])
+        # plt.plot(grad_time[2][0:50], grad_signal[2][0:50])
+        # plt.show()
+
+        start_time = 0
+        end_time = grad_time[0][-1]
+        time_step = abs(dict["t_rf"][1] - dict["t_rf"][0])  # Растр (шаг) времени
+
+        # Создаём равномерную временную сетку
+        time_array = np.arange(start_time, end_time + time_step, time_step)
+
+        # Интерполируем значения амплитуды на этой сетке
+        amplitude_array = np.interp(time_array, grad_time[0], grad_signal[0])
+        plt.plot(time_array, amplitude_array, label="Gx")
+        amplitude_array = np.interp(time_array, grad_time[1], grad_signal[1])
+        plt.plot(time_array, amplitude_array, label="Gy")
+        amplitude_array = np.interp(time_array, grad_time[2], grad_signal[2])
+        plt.plot(time_array, amplitude_array, label="Gz")
+        plt.plot(rf_time, rf_signal)
+        plt.legend()
+        plt.xlim((0, 0.02))
+        # plt.plot(dict["t_rf"][0:7000], np.abs(dict["rf"])[0:7000])
+        plt.show()

+ 48 - 0
services/seq-interp/src/interfaces/picoscope_exporter.py

@@ -0,0 +1,48 @@
+from yattag import Doc, indent
+
+
+class PicoScopeExporter:
+    """
+    Генератор picoscope_params.xml в формате test1_full/srv_interp.py
+    """
+
+    def generate(
+        self,
+        adc_values,
+        adc_starts,
+        path: str,
+        hw,
+        sampling_freq: float = 8e6,
+        num_channels: int = 3,
+    ):
+        adc_out_timings = [int(i * sampling_freq) for i in adc_values]
+
+        doc, tag, text, line = Doc().ttl()
+        with tag("root"):
+            with tag("points"):
+                with tag("title"):
+                    text("Points")
+                with tag("value"):
+                    text(str(adc_out_timings))
+
+            with tag("num_of_channels"):
+                with tag("title"):
+                    text("Number of Channels")
+                with tag("value"):
+                    text(num_channels)
+
+            with tag("times"):
+                with tag("title"):
+                    text("Times")
+                with tag("value"):
+                    text(str([float(i) for i in adc_starts]))
+
+            with tag("sample_freq"):
+                with tag("title"):
+                    text("Sample Frequency")
+                with tag("value"):
+                    text(sampling_freq)
+
+        xml_string = indent(doc.getvalue(), indentation=" " * 4, newline="\r")
+        with open(f"{path}/picoscope_params.xml", "w", encoding="utf-8") as f:
+            f.write(xml_string)

+ 141 - 0
services/seq-interp/src/interfaces/post_request_generator.py

@@ -0,0 +1,141 @@
+import json
+import logging
+import os
+from datetime import datetime
+from pathlib import Path
+
+logger = logging.getLogger("MRISequenceInterpreter")
+
+
+class PostRequestGenerator:
+    """
+    Генератор POST-запроса для аппаратного сервиса.
+
+    Формирует JSON-манифест в формате, совместимом с тестовой машиной
+    (см. POST_request_mes.txt), подставляя реальные имена выходных файлов
+    интерпретатора. При наличии hw_service_url отправляет запрос асинхронно.
+    """
+
+    # ------------------------------------------------------------------
+    # Public interface
+    # ------------------------------------------------------------------
+
+    def build(
+        self,
+        seq_data: dict,
+        adc_values: list,
+        sequence_path: str,
+        output_dir: str,
+        hw_cfg: dict,
+        rf_raster_time: float,
+    ) -> dict:
+        """
+        Строит словарь POST-запроса.
+
+        Parameters
+        ----------
+        seq_data        : выходной словарь PulseqLoader (для определения наличия градиентов)
+        adc_values      : длительности ADC-окон в секундах (из xml_generator)
+        sequence_path   : путь к исходному .seq файлу
+        output_dir      : директория с выходными артефактами
+        hw_cfg          : содержимое hw_config.json
+        rf_raster_time  : шаг RF-растра в секундах (из params["rf_raster_time"])
+        """
+        prefix = hw_cfg.get("file_path_prefix", "")
+
+        iadc_cfg = dict(hw_cfg.get("iadc", {}))
+        srate = iadc_cfg.get("srate", 8_000_000)
+        iadc_cfg["points"] = [int(v * srate) for v in adc_values]
+
+        isync_cfg = dict(hw_cfg.get("isync", {}))
+        isync_cfg["file"] = prefix + "sync_v2.xml"
+
+        isdr_cfg = dict(hw_cfg.get("isdr", {}))
+        isdr_cfg["file"] = prefix + f"rf_{rf_raster_time}_raster.bin"
+
+        igrax_cfg = dict(hw_cfg.get("igrax", {}))
+        igray_cfg = dict(hw_cfg.get("igray", {}))
+        igraz_cfg = dict(hw_cfg.get("igraz", {}))
+
+        # Включаем ось, если в seq_data присутствует соответствующий сигнал
+        igrax_cfg["file"] = prefix + "gx.txt"
+        igray_cfg["file"] = prefix + "gy.txt"
+        igraz_cfg["file"] = prefix + "gz.txt"
+
+        if "gx" in seq_data:
+            igrax_cfg["enabled"] = True
+        if "gy" in seq_data:
+            igray_cfg["enabled"] = True
+        if "gz" in seq_data:
+            igraz_cfg["enabled"] = True
+
+        infostr = Path(sequence_path).stem
+
+        payload = {
+            "info": {
+                "infostr": infostr,
+                "engine": "DefaultEngine",
+                "time": datetime.now().strftime("%Y-%m-%d %H:%M"),
+                "iadc": iadc_cfg,
+                "isync": isync_cfg,
+                "isdr": isdr_cfg,
+                "igrax": igrax_cfg,
+                "igray": igray_cfg,
+                "igraz": igraz_cfg,
+            }
+        }
+        return payload
+
+    def write(self, payload: dict, output_dir: str) -> str:
+        """
+        Сохраняет payload в <output_dir>/post_request.json.
+        Возвращает абсолютный путь к файлу.
+        """
+        os.makedirs(output_dir, exist_ok=True)
+        out_path = os.path.join(output_dir, "post_request.json")
+        with open(out_path, "w", encoding="utf-8") as f:
+            json.dump(payload, f, indent=4)
+        logger.info("POST-запрос сохранён: %s", out_path)
+        return out_path
+
+    async def send(self, payload: dict, url: str) -> int:
+        """
+        Асинхронно отправляет payload как JSON POST на url.
+        Возвращает HTTP-статус ответа (или 0 при ошибке соединения).
+        Ошибки логируются, но не прерывают интерпретацию.
+        """
+        try:
+            import httpx
+            async with httpx.AsyncClient(timeout=10.0) as client:
+                response = await client.post(url, json=payload)
+            logger.info(
+                "POST отправлен на %s — статус: %d", url, response.status_code
+            )
+            return response.status_code
+        except ImportError:
+            # Резервный вариант: синхронный requests через поток
+            return await self._send_via_requests(payload, url)
+        except Exception as exc:
+            logger.warning("Не удалось отправить POST на %s: %s", url, exc)
+            return 0
+
+    # ------------------------------------------------------------------
+    # Private helpers
+    # ------------------------------------------------------------------
+
+    @staticmethod
+    async def _send_via_requests(payload: dict, url: str) -> int:
+        import asyncio
+        try:
+            import requests as _requests
+
+            def _do_post():
+                resp = _requests.post(url, json=payload, timeout=10)
+                return resp.status_code
+
+            status = await asyncio.to_thread(_do_post)
+            logger.info("POST отправлен на %s — статус: %d", url, status)
+            return status
+        except Exception as exc:
+            logger.warning("Не удалось отправить POST на %s: %s", url, exc)
+            return 0

+ 92 - 0
services/seq-interp/src/interfaces/pulseq_adapter.py

@@ -0,0 +1,92 @@
+import json
+import logging
+import os
+
+from LF_scanner.pypulseq import Sequence
+
+from seq_interp.src.hardware.constraints import HardwareConstraints
+
+
+class PulseqLoader:
+    """
+    Адаптер для загрузки последовательностей в формате Pulseq (.seq файлы).
+    """
+
+    def __init__(self, hw: HardwareConstraints):
+        self.hw = hw
+        self.logger = logging.getLogger("PulseqLoader")
+
+    def load(self, path: str) -> dict:
+        """
+        Читает Pulseq-файл и возвращает структуру данных последовательности.
+        """
+        seq = Sequence(system=self.hw)
+        seq.read(path)
+
+        blocks = self._parse_blocks(seq)
+        waveforms = seq.waveforms_export()
+        params = self._load_params(path)
+
+        seq_data = {
+            "blocks": blocks,
+            "sequence": seq,
+            "params": params,
+        }
+        seq_data.update(waveforms)
+
+        self.logger.info(
+            "Файл %s загружен. Осталось %d блоков после удаления нулевой длительности.",
+            path,
+            len(blocks),
+        )
+        return seq_data
+
+    def _load_params(self, seq_path: str) -> dict:
+        json_path = os.path.splitext(seq_path)[0] + ".json"
+        defaults = {
+            "G_amp_max": self.hw.GRAD_MAX,
+            "grad_raster_time": self.hw.grad_raster_time,
+            "rf_raster_time": self.hw.rf_raster_time,
+            "gamma": self.hw.gamma,
+            "scale_rf": 1.0,
+        }
+        if not os.path.exists(json_path):
+            return defaults
+
+        with open(json_path, "r", encoding="utf-8") as f:
+            loaded = json.load(f)
+        defaults.update(loaded)
+        return defaults
+
+    def _parse_blocks(self, seq) -> list:
+        """
+        Формирует список блоков из объекта Sequence.
+        """
+        blocks = []
+        for block_id in seq.block_events:
+            block = seq.get_block(block_id)
+            duration = seq.block_durations[block_id]
+            if duration == 0:
+                self.logger.warning(
+                    "Удалён блок (ID: %d) с нулевой длительностью.",
+                    block_id,
+                )
+                continue
+
+            block_type = []
+            has_adc = False
+            if getattr(block, "rf", None) is not None:
+                block_type.append("RF")
+            if getattr(block, "adc", None) is not None:
+                block_type.append("ADC")
+                has_adc = True
+            if any(getattr(block, axis, None) is not None for axis in ("gx", "gy", "gz")):
+                block_type.append("GRAD")
+
+            blocks.append({
+                "type": block_type,
+                "duration": duration,
+                "has_adc": has_adc,
+            })
+
+        return blocks

+ 57 - 0
services/seq-interp/src/interfaces/rf_exporter.py

@@ -0,0 +1,57 @@
+import numpy as np
+
+
+class RFExporter:
+    """
+    Экспорт RF в формате test1_full/srv_interp.py
+    """
+
+    @staticmethod
+    def _radio_ampl_convertation(rf_ampl, t_rf, rf_raster_local):
+        out_rf_list = []
+        rf_ampl_raster = 127
+        rf_ampl_maximum = np.abs(max(rf_ampl)) if len(rf_ampl) else 0
+        if rf_ampl_maximum == 0:
+            return out_rf_list
+
+        proportional_cf_rf = rf_ampl_raster / rf_ampl_maximum
+        num_zeroes = 0
+        for rf_iter in range(len(rf_ampl) - 1):
+            if abs(t_rf[rf_iter] - t_rf[rf_iter + 1]) > 2 * rf_raster_local:
+                num_zeroes += int(np.abs((t_rf[rf_iter] - t_rf[rf_iter + 1]) / rf_raster_local))
+            else:
+                out_rf_list += [0] * num_zeroes
+                num_zeroes = 0
+                out_rf_list.append(round(rf_ampl[rf_iter].real * proportional_cf_rf))
+                out_rf_list.append(round(rf_ampl[rf_iter].imag * proportional_cf_rf))
+        return out_rf_list
+
+    def export(self, waveforms: dict, params: dict, output_dir: str):
+        rf_raster_local = params["rf_raster_time"]
+
+        if rf_raster_local == 5e-7:
+            empty_block_time_delay = 17.7e-6
+        elif rf_raster_local == 2.5e-7:
+            empty_block_time_delay = 3.6e-6
+        elif rf_raster_local == 5e-8:
+            empty_block_time_delay = 1.77e-6
+        else:
+            empty_block_time_delay = 0
+
+        rf_out = [0] * int(2 * (empty_block_time_delay // rf_raster_local))
+        rf_out += self._radio_ampl_convertation(
+            waveforms["rf"],
+            waveforms["t_rf"],
+            rf_raster_local,
+        )
+
+        scale_rf = params.get("scale_rf", 1.0)
+        rf_out = [round(x * scale_rf) for x in rf_out]
+
+        file_path = f"{output_dir}/rf_{rf_raster_local}_raster.bin"
+        with open(file_path, "wb") as file_rf:
+            for byte in rf_out:
+                file_rf.write(int(byte).to_bytes(1, byteorder="big", signed=True))
+
+        np.savetxt(f"{output_dir}/rf_time.txt", np.transpose(waveforms["t_rf"]))
+        np.savetxt(f"{output_dir}/rf_ampl.txt", np.transpose(waveforms["rf"]))

+ 69 - 0
services/seq-interp/src/interfaces/xml_generator.py

@@ -0,0 +1,69 @@
+from yattag import Doc, indent
+
+
+class XMLGenerator:
+    """
+    Генератор XML синхронизации в формате, совместимом с test1_full/srv_interp.py
+    """
+
+    def generate(self, sync_data: dict, path: str, hw):
+        number_of_blocks = sync_data["number_of_blocks"]
+        gate_rf = sync_data["gate_rf"]
+        gate_tr_switch = sync_data["gate_tr_switch"]
+        gate_adc = sync_data["gate_adc"]
+        blocks_duration = sync_data["blocks_duration"]
+        synchro_block_timer = sync_data["synchro_block_timer"]
+        min_block_time = sync_data["min_block_time"]
+
+        doc, tag, text = Doc().tagtext()
+
+        with tag("root"):
+            with tag("ParamCount"):
+                text(number_of_blocks + 1)
+
+            adc_times_values = []
+            adc_times_starts = []
+
+            with tag("RF"):
+                with tag("RF1"):
+                    text(0)
+                for rf_iter in range(number_of_blocks):
+                    with tag("RF" + str(rf_iter + 2)):
+                        text(gate_rf[rf_iter])
+
+            with tag("SW"):
+                with tag("SW1"):
+                    text(1)
+                for sw_iter in range(number_of_blocks):
+                    with tag("SW" + str(sw_iter + 2)):
+                        text(gate_tr_switch[sw_iter])
+
+            with tag("ADC"):
+                with tag("ADC1"):
+                    text(0)
+                for adc_iter in range(number_of_blocks):
+                    if gate_adc[adc_iter] == 1:
+                        adc_times_values.append(blocks_duration[adc_iter])
+                        adc_times_starts.append(sum(blocks_duration[0:adc_iter]))
+                    with tag("ADC" + str(adc_iter + 2)):
+                        text(gate_adc[adc_iter])
+
+            with tag("GR"):
+                with tag("GR1"):
+                    text(1)
+                for gr_iter in range(number_of_blocks):
+                    with tag("GR" + str(gr_iter + 2)):
+                        text(0)
+
+            with tag("CL"):
+                with tag("CL1"):
+                    text(int(min_block_time / synchro_block_timer))
+                for cl_iter in range(number_of_blocks):
+                    with tag("CL" + str(cl_iter + 2)):
+                        text(int(blocks_duration[cl_iter] / synchro_block_timer))
+
+        xml_string = indent(doc.getvalue(), indentation=" " * 4, newline="\r")
+        with open(path, "w", encoding="utf-8") as f:
+            f.write(xml_string)
+
+        return adc_times_values, adc_times_starts

+ 103 - 0
services/seq-interp/src/main.py

@@ -0,0 +1,103 @@
+import asyncio
+import logging
+import os
+from datetime import datetime
+
+from seq_interp.src.hardware.constraints import HardwareConstraints
+from seq_interp.src.interfaces.pulseq_adapter import PulseqLoader
+from seq_interp.src.core.synchronizer import Synchronizer
+from seq_interp.src.interfaces.xml_generator import XMLGenerator
+from seq_interp.src.interfaces.rf_exporter import RFExporter
+from seq_interp.src.interfaces.gradient_exporter import GradientExporter
+from seq_interp.src.interfaces.picoscope_exporter import PicoScopeExporter
+from seq_interp.src.interfaces.post_request_generator import PostRequestGenerator
+from seq_interp.src.config import config
+
+
+async def main(sequence_path: str = None, hw_config_path: str = None, output_dir: str = "output") -> None:
+    """
+    Точка входа интерпретатора MRI-последовательности.
+    """
+    log_dir = "log"
+    os.makedirs(log_dir, exist_ok=True)
+
+    log_filename = os.path.join(log_dir, f"interp_log_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.log")
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(message)s",
+        handlers=[logging.FileHandler(log_filename), logging.StreamHandler()],
+    )
+    logger = logging.getLogger("MRISequenceInterpreter")
+
+    if not sequence_path:
+        raise ValueError("sequence_path is required")
+
+    hw = HardwareConstraints(json_path=hw_config_path)
+    loader = PulseqLoader(hw)
+    seq_data = loader.load(sequence_path)
+
+    os.makedirs(output_dir, exist_ok=True)
+
+    sync_sequence = seq_data["sequence"]
+    params = seq_data["params"]
+
+    synchronizer = Synchronizer(hw)
+    sync_data = synchronizer.process(sync_sequence)
+
+    xml_generator = XMLGenerator()
+    rf_exporter = RFExporter()
+    grad_exporter = GradientExporter()
+    pico_exporter = PicoScopeExporter()
+    post_generator = PostRequestGenerator()
+
+    hw_cfg = config.hw_config  # полный словарь из hw_config.json
+
+    xml_path = os.path.join(output_dir, "sync_v2.xml")
+
+    adc_values, adc_starts = await asyncio.to_thread(xml_generator.generate, sync_data, xml_path, hw)
+
+    tasks = []
+    tasks.append(asyncio.to_thread(rf_exporter.export, seq_data, params, output_dir))
+
+    if all(k in seq_data for k in ["gx", "gy", "gz", "t_gx", "t_gy", "t_gz"]):
+        tasks.append(asyncio.to_thread(grad_exporter.export, seq_data, params, output_dir))
+
+    iadc_cfg = hw_cfg.get("iadc", {})
+    tasks.append(asyncio.to_thread(
+        pico_exporter.generate,
+        adc_values, adc_starts, output_dir, hw,
+        sampling_freq=iadc_cfg.get("srate", 8e6),
+        num_channels=iadc_cfg.get("n_channels", 3),
+    ))
+
+    await asyncio.gather(*tasks)
+
+    # Генерация и (опционально) отправка POST-манифеста
+    post_payload = post_generator.build(
+        seq_data=seq_data,
+        adc_values=adc_values,
+        sequence_path=sequence_path,
+        output_dir=output_dir,
+        hw_cfg=hw_cfg,
+        rf_raster_time=params["rf_raster_time"],
+    )
+    post_generator.write(post_payload, output_dir)
+
+    hw_service_url = hw_cfg.get("hw_service_url")
+    if hw_service_url:
+        await post_generator.send(post_payload, hw_service_url)
+
+    logger.info("Интерпретация завершена, результаты записаны в: %s", output_dir)
+
+
+if __name__ == "__main__":
+    script_dir = os.path.dirname(os.path.abspath(__file__))
+    project_root = os.path.dirname(os.path.dirname(script_dir))
+
+    asyncio.run(
+        main(
+            sequence_path=os.path.join(project_root, "seq_interp", "data", "input", "rf_adc_only_fixed3 (4).seq"),
+            hw_config_path=os.path.join(project_root, "seq_interp", "cfg", "hw_config.json"),
+            output_dir=os.path.join(project_root, "seq_interp", "data", "output"),
+        )
+    )

+ 0 - 0
services/seq-interp/src/utils/__init__.py


+ 15 - 0
services/seq-interp/src/utils/cumsum.py

@@ -0,0 +1,15 @@
+def cumsum(a, b, c=None, d=None, e=None):
+    if e is not None:
+        s1 = a + b
+        s2 = s1 + c
+        s3 = s2 + d
+        return a, s1, s2, s3, s3 + e
+    elif d is not None:
+        s1 = a + b
+        s2 = s1 + c
+        return a, s1, s2, s2 + d
+    elif c is not None:
+        s = a + b
+        return a, s, s + c
+    else:
+        return a, a + b

+ 8 - 0
services/seq-interp/src/utils/dataclass.py

@@ -0,0 +1,8 @@
+from dataclasses import dataclass
+@dataclass
+class TTLBlock:
+    RF: int = 0
+    SW: int = 0
+    ADC: int = 0
+    GR: int = 1
+    duration: int = 0  # в тиках

+ 117 - 0
services/seq-interp/src/utils/vizualizator.py

@@ -0,0 +1,117 @@
+import matplotlib.pyplot as plt
+import numpy as np
+from typing import List
+from ..utils.dataclass import TTLBlock
+from ..hardware.constraints import HardwareConstraints
+
+
+def plot_ttl_timeline(
+        ttl_blocks: List[TTLBlock],
+        original_blocks: List[dict],
+        tick_duration: float = 20e-9
+):
+    """
+    Визуализирует TTL timeline + оригинальные сигналы:
+    RF и ADC отображаются как синусоиды, GRAD как трапеция.
+    Оригинальные сигналы сдвигаются вправо с учётом начального TTL-блока.
+
+    :param ttl_blocks: список TTLBlock объектов (результат Synchronizer)
+    :param original_blocks: список исходных блоков {"type": [...], "duration": float}
+    :param tick_duration: длительность одного тика (сек)
+    """
+    # === TTL timeline ===
+    time = 0
+    ttl_stream = {"RF": [], "SW": [], "ADC": [], "GR": []}
+    time_stream = []
+
+    for block in ttl_blocks:
+        for _ in range(block.duration):
+            time_stream.append(time)
+            ttl_stream["RF"].append(block.RF)
+            ttl_stream["SW"].append(block.SW)
+            ttl_stream["ADC"].append(block.ADC)
+            ttl_stream["GR"].append(block.GR)
+            time += tick_duration
+
+    # === Исходные сигналы (RF, ADC — синус; GRAD — трапеция) ===
+    signal_plot = {"RF": [], "ADC": [], "GRAD": []}
+    signal_time = []
+    t = 0
+
+    for block in original_blocks:
+        duration = block["duration"]
+        ticks = int(duration / tick_duration)
+        t_block = np.linspace(t, t + duration, ticks)
+
+        # RF синус
+        if "RF" in block["type"]:
+            signal_plot["RF"].extend(np.sin(2 * np.pi * 20e6 * t_block))  # 20 кГц
+        else:
+            signal_plot["RF"].extend([0] * ticks)
+
+        # ADC синус, меньшей амплитудой
+        if "ADC" in block["type"]:
+            signal_plot["ADC"].extend(0.5 * np.sin(2 * np.pi * 10e6 * t_block))
+        else:
+            signal_plot["ADC"].extend([0] * ticks)
+
+        # GRAD — трапеция
+        if "GRAD" in block["type"]:
+            ramp_len = ticks // 4
+            plateau_len = ticks // 2
+            trapezoid = list(np.linspace(0, 1, ramp_len)) + \
+                        [1] * plateau_len + \
+                        list(np.linspace(1, 0, ticks - ramp_len - plateau_len))
+            signal_plot["GRAD"].extend(trapezoid)
+        else:
+            signal_plot["GRAD"].extend([0] * ticks)
+
+        signal_time.extend(t_block)
+        t += duration
+    hw = HardwareConstraints()
+    # === Сдвиг исходных сигналов на START_DELAY ===
+    start_offset = tick_duration * (
+            round(hw.START_DELAY / tick_duration) +
+            max(
+                round(hw.RF_DELAY / tick_duration),
+                round(hw.TR_DELAY / tick_duration),
+                round(hw.GRAD_DELAY / tick_duration)
+            )
+    )
+    adjusted_signal_time = [t + start_offset for t in signal_time]
+
+    # === Визуализация ===
+    plt.figure(figsize=(14, 8))
+
+    # TTL сигналы — логика
+    for i, (label, values) in enumerate(ttl_stream.items()):
+        plt.step(
+            [t * 1e3 for t in time_stream],
+            [v + i * 2 for v in values],
+            where='post',
+            label=f"TTL: {label}"
+        )
+
+    # Исходные сигналы (синус/трапеция)
+    offset_map = {"RF": 8, "ADC": 10, "GRAD": 12}
+    color_map = {"RF": "blue", "ADC": "purple", "GRAD": "orange"}
+
+    for label in signal_plot:
+        y = np.array(signal_plot[label]) + offset_map[label]
+        plt.plot(
+            [t * 1e3 for t in adjusted_signal_time],
+            y,
+            label=f"Signal: {label}",
+            color=color_map[label]
+        )
+
+    # Финальные штрихи
+    plt.title("TTL Timeline + Original Signal Shapes (с учётом задержек)")
+    plt.xlabel("Time (mks)")
+    yticks = [i * 2 for i in range(4)] + list(offset_map.values())
+    ylabels = list(ttl_stream.keys()) + list(offset_map.keys())
+    plt.yticks(yticks, ylabels)
+    plt.grid(True)
+    plt.legend(loc="upper right")
+    plt.tight_layout()
+    plt.show()

+ 0 - 0
services/seq-interp/tests/__init__.py


+ 304 - 0
services/seq-interp/tests/test_adapters.py

@@ -0,0 +1,304 @@
+"""
+Unit tests for seq_interp.src.gui.adapters — pure data-conversion helpers,
+no Qt or hardware dependencies required.
+"""
+import math
+import pytest
+import numpy as np
+
+from seq_interp.src.gui.adapters import (
+    gate_to_step,
+    build_block_rows,
+    find_block_at_time,
+    validate_timing,
+    seq_metadata,
+    block_cumtimes,
+)
+
+
+# ─── Fixtures / helpers ────────────────────────────────────────────────────────
+
+class _HW:
+    """Minimal stand-in for HardwareConstraints."""
+    rf_raster_time       = 1e-6
+    grad_raster_time     = 10e-6
+    adc_raster_time      = 100e-9
+    block_duration_raster = 10e-6
+    RF_DELAY             = 800e-9
+    TR_DELAY             = 800e-9
+    START_DELAY          = 1600e-9
+    MIN_BLOCK_DURATION   = 20e-9
+    gamma                = 42.576e6
+    GRAD_MAX             = 9e-3 * 42.576e6
+
+
+def _make_seq_data(blocks: list, rf=None, t_rf=None,
+                   gx=None, t_gx=None, gy=None, t_gy=None, gz=None, t_gz=None):
+    return {
+        "blocks": blocks,
+        "params": {"scale_rf": 1.0},
+        "rf":  rf   or np.array([]),
+        "t_rf": t_rf or np.array([]),
+        "gx":  gx   or np.array([]),
+        "t_gx": t_gx or np.array([]),
+        "gy":  gy   or np.array([]),
+        "t_gy": t_gy or np.array([]),
+        "gz":  gz   or np.array([]),
+        "t_gz": t_gz or np.array([]),
+    }
+
+
+def _make_sync_data(gate_rf, gate_adc, gate_tr, durs, n_blocks=None):
+    return {
+        "gate_rf":         list(gate_rf),
+        "gate_adc":        list(gate_adc),
+        "gate_tr_switch":  list(gate_tr),
+        "blocks_duration": list(durs),
+        "number_of_blocks": n_blocks if n_blocks is not None else len(durs) - 1,
+        "synchro_block_timer": 20e-9,
+        "min_block_time":  800e-9,
+    }
+
+
+# ─── gate_to_step ──────────────────────────────────────────────────────────────
+
+class TestGateToStep:
+    def test_output_length(self):
+        gate = [0, 1, 0]
+        durs = [1e-3, 2e-3, 1e-3]
+        t, v = gate_to_step(gate, durs)
+        assert len(t) == 6
+        assert len(v) == 6
+
+    def test_values_match_gate(self):
+        gate = [0, 1, 0]
+        durs = [1e-3, 2e-3, 1e-3]
+        t, v = gate_to_step(gate, durs)
+        assert v[0] == 0
+        assert v[2] == 1
+        assert v[4] == 0
+
+    def test_time_monotonic(self):
+        gate = [1, 0, 1, 0]
+        durs = [0.5e-3] * 4
+        t, _ = gate_to_step(gate, durs)
+        assert all(t[i] <= t[i + 1] for i in range(len(t) - 1))
+
+    def test_empty_gate(self):
+        t, v = gate_to_step([], [])
+        assert len(t) == 0 and len(v) == 0
+
+    def test_step_coverage(self):
+        """Each gate value spans exactly its block duration."""
+        gate = [1]
+        durs = [2e-3]
+        t, v = gate_to_step(gate, durs)
+        assert math.isclose(t[1] - t[0], 2e-3)
+
+
+# ─── block_cumtimes ────────────────────────────────────────────────────────────
+
+class TestBlockCumtimes:
+    def test_starts_at_zero(self):
+        ct = block_cumtimes([1e-3, 2e-3, 3e-3])
+        assert ct[0] == pytest.approx(0.0)
+
+    def test_length(self):
+        ct = block_cumtimes([1e-3, 2e-3])
+        assert len(ct) == 3
+
+    def test_values(self):
+        ct = block_cumtimes([1e-3, 2e-3, 3e-3])
+        assert ct[-1] == pytest.approx(6e-3)
+
+
+# ─── build_block_rows ─────────────────────────────────────────────────────────
+
+class TestBuildBlockRows:
+    def _simple_sync(self, blocks):
+        """
+        Produce a minimal consistent sync_data that matches what Synchronizer would
+        generate for the given block list.  Keeps bookkeeping manual but deterministic.
+        """
+        start = 1.6e-6
+        rf_delay = 0.8e-6
+        tr_delay = 0.8e-6
+        block_dur = 5e-3
+
+        g_rf, g_adc, g_tr, durs = [0], [0], [1], [start]
+        for blk in blocks:
+            has_adc = blk.get("has_adc", False)
+            has_rf  = "RF" in blk.get("type", [])
+            if has_adc:
+                g_adc.append(0); g_rf.append(g_rf[-1])
+                durs[-1] -= tr_delay; durs.append(tr_delay)
+                g_tr.append(0)
+                g_adc.append(1); g_tr.append(0)
+            else:
+                g_tr.append(1); g_adc.append(0)
+            if has_rf and not has_adc:
+                g_rf.append(1); g_adc.append(g_adc[-1])
+                durs[-1] -= rf_delay; durs.append(rf_delay)
+                g_tr.append(g_tr[-1])
+                g_rf.append(1)
+            else:
+                g_rf.append(0)
+            durs.append(block_dur)
+        return _make_sync_data(g_rf, g_adc, g_tr, durs)
+
+    def test_start_delay_is_first_row(self):
+        blocks = [{"type": [], "duration": 5e-3, "has_adc": False}]
+        sync = self._simple_sync(blocks)
+        seq  = _make_seq_data(blocks)
+        rows = build_block_rows(seq, sync)
+        assert rows[0].is_delay
+        assert rows[0].delay_type == "START"
+        assert rows[0].sync_index == 0
+
+    def test_plain_block_no_delay_inserted(self):
+        blocks = [{"type": [], "duration": 5e-3, "has_adc": False}]
+        sync = self._simple_sync(blocks)
+        seq  = _make_seq_data(blocks)
+        rows = build_block_rows(seq, sync)
+        # start_delay + 1 actual block = 2 rows
+        assert len(rows) == 2
+        assert not rows[1].is_delay
+
+    def test_rf_block_inserts_rf_delay(self):
+        blocks = [{"type": ["RF"], "duration": 5e-3, "has_adc": False}]
+        sync = self._simple_sync(blocks)
+        seq  = _make_seq_data(blocks)
+        rows = build_block_rows(seq, sync)
+        # start_delay + RF_DELAY + RF_block = 3 rows
+        assert len(rows) == 3
+        assert rows[1].delay_type == "RF"
+        assert rows[2].has_rf
+
+    def test_adc_block_inserts_tr_delay(self):
+        blocks = [{"type": ["ADC"], "duration": 5e-3, "has_adc": True}]
+        sync = self._simple_sync(blocks)
+        seq  = _make_seq_data(blocks)
+        rows = build_block_rows(seq, sync)
+        # start_delay + TR_DELAY + ADC_block = 3 rows
+        assert len(rows) == 3
+        assert rows[1].delay_type == "TR"
+        assert rows[2].has_adc
+
+    def test_t_start_t_end_coverage(self):
+        blocks = [
+            {"type": [], "duration": 5e-3, "has_adc": False},
+            {"type": [], "duration": 5e-3, "has_adc": False},
+        ]
+        sync = self._simple_sync(blocks)
+        seq  = _make_seq_data(blocks)
+        rows = build_block_rows(seq, sync)
+        for r in rows:
+            assert r.t_end > r.t_start
+
+    def test_empty_blocks(self):
+        sync = _make_sync_data([0], [0], [1], [1.6e-6])
+        seq  = _make_seq_data([])
+        rows = build_block_rows(seq, sync)
+        assert len(rows) == 1
+        assert rows[0].delay_type == "START"
+
+
+# ─── find_block_at_time ───────────────────────────────────────────────────────
+
+class TestFindBlockAtTime:
+    def _rows(self):
+        blocks = [{"type": [], "duration": 5e-3, "has_adc": False}] * 3
+        sync   = {
+            "gate_rf": [0, 0, 0, 0], "gate_adc": [0, 0, 0, 0],
+            "gate_tr_switch": [1, 1, 1, 1],
+            "blocks_duration": [1e-3, 5e-3, 5e-3, 5e-3],
+            "number_of_blocks": 3, "synchro_block_timer": 20e-9,
+            "min_block_time": 800e-9,
+        }
+        return build_block_rows(_make_seq_data(blocks), sync)
+
+    def test_finds_correct_row(self):
+        rows = self._rows()
+        # First block: [0, 1e-3), second: [1e-3, 6e-3) …
+        found = find_block_at_time(0.5e-3, rows)
+        assert found is not None
+        assert found.t_start == pytest.approx(0.0)
+
+    def test_returns_none_past_end(self):
+        rows = self._rows()
+        found = find_block_at_time(1e3, rows)
+        assert found is None
+
+    def test_boundary_belongs_to_next(self):
+        rows = self._rows()
+        # Exactly at t=1e-3 should be the SECOND block (t_start=1e-3)
+        found = find_block_at_time(1e-3, rows)
+        assert found is not None
+        assert found.t_start == pytest.approx(1e-3)
+
+
+# ─── validate_timing ──────────────────────────────────────────────────────────
+
+class TestValidateTiming:
+    def test_no_warnings_for_valid_data(self):
+        hw = _HW()
+        seq = _make_seq_data([])
+        sync = _make_sync_data([0], [0], [1], [20e-9])
+        warns = validate_timing(hw, seq, sync)
+        assert warns == []
+
+    def test_rf_delay_below_min(self):
+        hw = _HW()
+        hw.RF_DELAY = 1e-9   # below MIN_BLOCK_DURATION = 20 ns
+        seq = _make_seq_data([])
+        sync = _make_sync_data([0], [0], [1], [20e-9])
+        warns = validate_timing(hw, seq, sync)
+        assert any("RF_DELAY" in w for w in warns)
+
+    def test_cl_rounds_to_zero(self):
+        hw = _HW()
+        # synchro_block_timer = 20e-9; duration = 10e-9 → CL = 0
+        seq = _make_seq_data([])
+        sync = _make_sync_data([0], [0], [1], [10e-9])
+        sync["synchro_block_timer"] = 20e-9
+        warns = validate_timing(hw, seq, sync)
+        assert any("CL rounds to zero" in w for w in warns)
+
+    def test_waveform_missing(self):
+        hw = _HW()
+        seq = {"blocks": [], "params": {}}  # no rf/t_rf
+        sync = _make_sync_data([0], [0], [1], [20e-9])
+        warns = validate_timing(hw, seq, sync)
+        assert any("'rf'" in w or "'t_rf'" in w for w in warns)
+
+    def test_length_mismatch(self):
+        hw = _HW()
+        seq = _make_seq_data([], rf=np.array([1.0, 2.0]), t_rf=np.array([0.0]))
+        sync = _make_sync_data([0], [0], [1], [20e-9])
+        warns = validate_timing(hw, seq, sync)
+        assert any("mismatch" in w.lower() for w in warns)
+
+
+# ─── seq_metadata ─────────────────────────────────────────────────────────────
+
+class TestSeqMetadata:
+    def test_counts(self):
+        hw = _HW()
+        blocks = [
+            {"type": ["RF"], "duration": 5e-3, "has_adc": False},
+            {"type": ["ADC"], "duration": 5e-3, "has_adc": True},
+            {"type": ["GRAD"], "duration": 5e-3, "has_adc": False},
+            {"type": [], "duration": 5e-3, "has_adc": False},
+        ]
+        meta = seq_metadata(_make_seq_data(blocks), hw)
+        assert meta["Total blocks (orig)"] == 4
+        assert meta["RF blocks"] == 1
+        assert meta["ADC blocks"] == 1
+        assert meta["Grad blocks"] == 1
+
+    def test_raster_times_in_human_units(self):
+        hw = _HW()
+        meta = seq_metadata(_make_seq_data([]), hw)
+        assert float(meta["RF raster (µs)"])   == pytest.approx(1.0)
+        assert float(meta["Grad raster (µs)"]) == pytest.approx(10.0)
+        assert float(meta["ADC raster (ns)"])  == pytest.approx(100.0)

+ 29 - 0
services/seq-interp/tests/test_pulseq_loader.py

@@ -0,0 +1,29 @@
+import pytest
+from ..src.interfaces.pulseq_adapter import PulseqLoader
+from ..src.hardware.constraints import HardwareConstraints
+
+@pytest.fixture
+def hw():
+    return HardwareConstraints()
+
+def test_pulseq_loader_removes_zero_duration_blocks(hw):
+    loader = PulseqLoader(hw)
+    seq_mock = {
+        "block_events": [1, 2, 3, 4, 5],  # 5 блоков
+        "block_durations": {1: 5e-3, 2: 0, 3: 3e-3, 4: 0, 5: 1e-3},  # Второй и четвёртый блоки = 0 (должны удалиться)
+        "blocks": {
+            1: {"rf": True, "gx": None, "gy": None, "gz": None, "adc": None},
+            2: {"rf": None, "gx": None, "gy": None, "gz": None, "adc": None},  # Длительность 0 (удалится)
+            3: {"rf": None, "gx": True, "gy": None, "gz": None, "adc": None},
+            4: {"rf": None, "gx": None, "gy": None, "gz": None, "adc": None},  # Длительность 0 (удалится)
+            5: {"rf": None, "gx": None, "gy": None, "gz": None, "adc": True},
+        }
+    }
+
+    parsed_blocks = loader._parse_blocks(seq_mock)
+
+    # Проверяем, что блоки с нулевой длительностью удалены
+    assert len(parsed_blocks) == 3  # Было 5, удалилось 2
+    assert parsed_blocks[0]["type"] == "RF"
+    assert parsed_blocks[1]["type"] == "GRAD"
+    assert parsed_blocks[2]["type"] == "GRAD"

+ 54 - 0
services/seq-interp/tests/test_synchronizer.py

@@ -0,0 +1,54 @@
+import os
+from ..src.core.synchronizer import Synchronizer, TimingEvent
+from ..src.hardware.constraints import HardwareConstraints
+
+def test_synchronizer_adds_delays():
+    hw = HardwareConstraints()  # используем значения по умолчанию
+    # Два блока: RF (без ADC) и градиент (с ADC)
+    blocks = [
+        {"type": "RF", "duration": 1e-3, "has_adc": False},
+        {"type": "GRAD", "duration": 2e-3, "has_adc": True}
+    ]
+    seq_data = {"blocks": blocks}
+    synch = Synchronizer(hw)
+    events = synch.process(seq_data)
+
+    # Проверяем общее количество событий:
+    # Ожидается: Start delay, RF delay, RF block, Grad block, TR delay = 5 событий
+    assert isinstance(events, list) and all(isinstance(e, TimingEvent) for e in events)
+    assert len(events) == 5
+
+    # Проверяем последовательность типов событий
+    types = [e.event_type for e in events]
+    expected_types = ["DELAY", "DELAY", "RF", "GRAD", "DELAY"]
+    assert types == expected_types
+
+    # Проверяем, что первая задержка имеет длительность соответствующую START_DELAY
+    start_delay_ticks = int(round(hw.START_DELAY / hw.MIN_BLOCK_DURATION))
+    assert events[0].event_type == "DELAY"
+    assert events[0].duration == start_delay_ticks
+
+    # Проверяем, что RF_DELAY вставлена перед RF блоком
+    rf_delay_ticks = int(round(hw.RF_DELAY / hw.MIN_BLOCK_DURATION))
+    assert events[1].event_type == "DELAY" and events[2].event_type == "RF"
+    assert events[1].duration == rf_delay_ticks
+
+    # Проверяем, что TR_DELAY вставлена после градиентного блока с ADC
+    tr_delay_ticks = int(round(hw.TR_DELAY / hw.MIN_BLOCK_DURATION))
+    assert events[-1].event_type == "DELAY"
+    assert events[-1].duration == tr_delay_ticks
+
+def test_synchronizer_timing_order():
+    hw = HardwareConstraints()
+    blocks = [
+        {"type": "GRAD", "duration": 5e-4, "has_adc": True},  # один блок с ADC
+    ]
+    events = Synchronizer(hw).process({"blocks": blocks})
+    # Должно быть: начальная задержка, блок, TR задержка (3 события)
+    assert events[0].start == 0  # начало первой задержки в 0
+    # Проверяем, что время старта каждого следующего события больше или равно предыдущего конца
+    for i in range(1, len(events)):
+        prev = events[i-1]
+        curr = events[i]
+        # start текущего должен равняться start предыдущего + duration предыдущего
+        assert curr.start == prev.start + prev.duration

+ 53 - 0
services/seq-interp/tests/test_xml_generator.py

@@ -0,0 +1,53 @@
+import os
+import xml.etree.ElementTree as ET
+from ..src.core.synchronizer import Synchronizer
+from ..src.hardware.constraints import HardwareConstraints
+from ..src.interfaces.xml_generator import XMLGenerator
+
+def test_xml_generator_output_structure(tmp_path):
+    hw = HardwareConstraints()
+    # Определяем простую последовательность: RF блок без ADC, затем Grad блок с ADC
+    blocks = [
+        {"type": "RF", "duration": 1e-4, "has_adc": False},
+        {"type": "GRAD", "duration": 2e-4, "has_adc": True}
+    ]
+    seq_data = {"blocks": blocks}
+    # Генерируем события синхронизации
+    events = Synchronizer(hw).process(seq_data)
+    # Генерация XML в временный файл
+    xml_path = tmp_path / "test_sync.xml"
+    xml_gen = XMLGenerator()
+    xml_gen.generate(events, str(xml_path), hw)
+    # Чтение и парсинг XML
+    tree = ET.parse(xml_path)
+    root = tree.getroot()
+    # Проверяем наличие основных разделов
+    sections = [elem.tag for elem in root]
+    assert "ParamCount" in sections and "RF" in sections and "SW" in sections and "ADC" in sections and "GR" in sections and "CL" in sections
+    # Проверяем правильность ParamCount
+    count = int(root.findtext("ParamCount"))
+    assert count == len(events)
+    # Проверяем значения триггеров RF, SW, ADC
+    rf_values = [int(child.text) for child in root.find("RF")]
+    sw_values = [int(child.text) for child in root.find("SW")]
+    adc_values = [int(child.text) for child in root.find("ADC")]
+    # Должно быть столько же значений, сколько событий
+    assert len(rf_values) == count
+    assert len(sw_values) == count
+    assert len(adc_values) == count
+    # Первый RF должен быть 0 (нет RF в начальной задержке), первый SW должен быть 1, первый ADC должен быть 0
+    assert rf_values[0] == 0
+    assert sw_values[0] == 1
+    assert adc_values[0] == 0
+    # Проверяем, что для RF-блока (второе событие RF) RF-триггер 1, ADC 0; для Grad-блока с ADC RF=0, ADC=1
+    # События: [DELAY(start), DELAY(RF_delay), RF, GRAD, DELAY(TR_delay)]
+    # RF-триггер должен быть 1 только на событии RF (индекс 2 списка events)
+    assert rf_values[2] == 1 and all(val in (0, 1) for val in rf_values)
+    # ADC-триггер должен быть 1 только на событии с ADC (Grad, индекс 3)
+    assert adc_values[3] == 1 and all(val in (0, 1) for val in adc_values)
+    # Проверяем секцию CL: количество тиков каждого события
+    cl_values = [int(child.text) for child in root.find("CL")]
+    # Количество CL значений равно числу событий, и первое значение соответствует стартовой задержке (в тиках)
+    assert len(cl_values) == count
+    start_delay_ticks = int(round(hw.START_DELAY / hw.MIN_BLOCK_DURATION))
+    assert cl_values[0] == start_delay_ticks

+ 36 - 0
services/seq-interp/tests/waveform_processor.py

@@ -0,0 +1,36 @@
+import numpy as np
+from ..src.core.waveform_processor import WaveformProcessor
+from ..src.hardware.constraints import HardwareConstraints
+
+def test_process_rf_numba_scaling():
+    # Тестируем масштабирование RF-сигнала
+    rf = np.array([0.0, 0.5, -0.5, 1.0, -1.0], dtype=np.float32)
+    max_val = 127
+    result = WaveformProcessor.process_rf_numba(rf, max_val)
+    # Ожидаем int8 значения
+    assert result.dtype == np.int8
+    expected = np.array([0, 64, -64, 127, -128], dtype=np.int8)
+    # Проверяем равенство с ожидаемым результатом (с учетом округления)
+    assert np.array_equal(result, expected)
+
+def test_process_gradient_numba_scaling():
+    # Тестируем масштабирование градиентного сигнала
+    grad = np.array([-1.0, -0.5, 0.0, 0.5, 1.0], dtype=np.float32)
+    max_val = 32767  # максимальное значение для int16
+    result = WaveformProcessor.process_gradient_numba(grad, max_val)
+    assert result.dtype == np.int16
+    expected = np.array([-32767, -16384, 0, 16384, 32767], dtype=np.int16)
+    assert np.array_equal(result, expected)
+
+def test_preprocess_adc_contiguity():
+    hw = HardwareConstraints()
+    wp = WaveformProcessor(hw)
+    # Создаем не смежный массив (шаг 2)
+    arr = np.array([1.0, 2.5, 3.0], dtype=np.float64)
+    arr_slice = arr[::2]  # это представление: [1.0, 3.0], не смежное в памяти
+    processed = wp.preprocess_adc(arr_slice)
+    # Проверяем, что результат смежен в памяти и имеет dtype float32
+    assert processed.dtype == np.float32
+    assert processed.flags['C_CONTIGUOUS'] is True
+    # Проверяем, что значения сохранились
+    assert np.allclose(processed, arr_slice.astype(np.float32))

+ 7 - 0
services/spectrometer/AddDevices.bat

@@ -0,0 +1,7 @@
+curl -v -u specadmin:specadmin -X POST -H "Content-Type: application/json" -d "{\"device_type\": \"ADC\", \"brend\": \"Picoscope\", \"serial_model\": \"PS4000A\", \"proto\": \"adc_default\", \"proto_interface\": \"TCP\"}" http://localhost:8000/api/devices/
+
+curl -v -u specadmin:specadmin -X POST -H "Content-Type: application/json" -d "{\"device_type\": \"SDR\", \"brend\": \"HackRF\", \"serial_model\": \"HackRF\", \"proto\": \"sdr_default\", \"proto_interface\": \"USB\"}" http://localhost:8000/api/devices/
+
+curl -v -u specadmin:specadmin -X POST -H "Content-Type: application/json" -d "{\"device_type\": \"SYNC\", \"brend\": \"Arduino\", \"serial_model\": \"DuePP\", \"proto\": \"sync_default\", \"proto_interface\": \"USB\"}" http://localhost:8000/api/devices/
+
+curl -v -u specadmin:specadmin -X POST -H "Content-Type: application/json" -d "{\"device_type\": \"GRA\", \"brend\": \"ITMO\", \"serial_model\": \"GRU\", \"proto\": \"gra_default\", \"proto_interface\": \"UDP\"}" http://localhost:8000/api/devices/

+ 27 - 0
services/spectrometer/CreateDB.sql

@@ -0,0 +1,27 @@
+CREATE ROLE specadmin WITH
+	LOGIN
+	NOSUPERUSER
+	NOCREATEDB
+	NOCREATEROLE
+	INHERIT
+	NOREPLICATION
+	NOBYPASSRLS
+	CONNECTION LIMIT -1
+	PASSWORD 'specadmin';
+
+CREATE DATABASE specdata
+    WITH
+    OWNER = specadmin
+    ENCODING = 'UTF8'
+    LOCALE_PROVIDER = 'libc'
+    CONNECTION LIMIT = -1
+    IS_TEMPLATE = False;
+
+ALTER ROLE specadmin IN DATABASE specdata
+    SET "TimeZone" TO 'UTC';
+ALTER ROLE specadmin IN DATABASE specdata
+    SET client_encoding TO 'utf8';
+ALTER ROLE specadmin IN DATABASE specdata
+    SET default_transaction_isolation TO 'read committed';
+
+GRANT ALL ON DATABASE specdata TO specadmin;

+ 23 - 0
services/spectrometer/Dockerfile

@@ -0,0 +1,23 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1 \
+    DJANGO_SETTINGS_MODULE=mserv00.settings \
+    DJANGO_ALLOWED_HOSTS=*
+
+RUN apt-get update && apt-get install -y --no-install-recommends curl \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+RUN sed -i "s/ALLOWED_HOSTS = \[.*/ALLOWED_HOSTS = ['*']/" mserv00/settings.py \
+    && python manage.py migrate --noinput
+
+EXPOSE 8000
+
+CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

+ 1 - 0
services/spectrometer/VERSION

@@ -0,0 +1 @@
+1.0.0

+ 1 - 0
services/spectrometer/autokill.bat

@@ -0,0 +1 @@
+taskkill /f /fi "IMAGENAME eq pico-tcp.exe"

+ 6 - 0
services/spectrometer/autorun_default.bat

@@ -0,0 +1,6 @@
+taskkill /f /fi "IMAGENAME eq pico-tcp.exe"
+.\mvenv\Scripts\activate.bat
+python -m pip install pip --upgrade
+python -m pip install -r requirements.txt
+START bin\pico-tcp.exe
+python manage.py runserver

+ 3 - 0
services/spectrometer/autorun_default_simple.bat

@@ -0,0 +1,3 @@
+taskkill /f /fi "IMAGENAME eq pico-tcp.exe"
+START /B bin\pico-tcp.exe
+python manage.py runserver

+ 7 - 0
services/spectrometer/autorun_default_venv.bat

@@ -0,0 +1,7 @@
+python -m venv mvenv
+taskkill /f /fi "IMAGENAME eq pico-tcp.exe"
+.\mvenv\Scripts\activate.bat
+python -m pip install pip --upgrade
+python -m pip install -r requirements.txt
+START bin\pico-tcp.exe
+python manage.py runserver

BIN
services/spectrometer/bin/Owin.dll


BIN
services/spectrometer/bin/Sync.exe


BIN
services/spectrometer/bin/hackrf.dll


BIN
services/spectrometer/bin/hackrfdll00.dll


BIN
services/spectrometer/bin/hackrftrans00.exe


BIN
services/spectrometer/bin/libgcc_s_seh-1.dll


BIN
services/spectrometer/bin/libstdc++-6.dll


BIN
services/spectrometer/bin/libwinpthread-1.dll


BIN
services/spectrometer/bin/msys-usb-1.0.dll


BIN
services/spectrometer/bin/pico-tcp.exe


BIN
services/spectrometer/bin/picocv.dll


BIN
services/spectrometer/bin/picoipp.dll


+ 3 - 0
services/spectrometer/bin/picologs/pico-log-20250807-171834.txt

@@ -0,0 +1,3 @@
+[2025-08-07 17:18:34:659] / [INFO]	Open socket
+[2025-08-07 17:18:34:660] / [INFO]	Socket initialized!
+[2025-08-07 17:18:34:660] / [INFO]	Wait for connection...

BIN
services/spectrometer/bin/picologs/pico-log-20250807-171848.txt


+ 3 - 0
services/spectrometer/bin/picologs/pico-log-20250822-124725.txt

@@ -0,0 +1,3 @@
+[2025-08-22 12:47:25:501] / [INFO]	Open socket
+[2025-08-22 12:47:25:502] / [INFO]	Socket initialized!
+[2025-08-22 12:47:25:502] / [INFO]	Wait for connection...

+ 3 - 0
services/spectrometer/bin/picologs/pico-log-20250822-140901.txt

@@ -0,0 +1,3 @@
+[2025-08-22 14:09:01:949] / [INFO]	Open socket
+[2025-08-22 14:09:01:950] / [INFO]	Socket initialized!
+[2025-08-22 14:09:01:950] / [INFO]	Wait for connection...

+ 22 - 0
services/spectrometer/bin/picologs/pico-log-20250822-140915.txt

@@ -0,0 +1,22 @@
+[2025-08-22 14:09:15:231] / [INFO]	Client connected
+[2025-08-22 14:09:15:231] / [INFO]	Command 0x01 received
+[2025-08-22 14:09:16:825] / [INFO]	Pico device opened with handle: 16384
+[2025-08-22 14:09:16:826] / [INFO]	Pico device LED flashed.
+[2025-08-22 14:09:16:826] / [INFO]	Pico device LED flashed.
+[2025-08-22 14:09:16:827] / [INFO]	Data sent to socket
+[2025-08-22 14:09:16:828] / [INFO]	Pico device opened
+[2025-08-22 14:09:16:828] / [INFO]	Request received
+[2025-08-22 14:09:16:828] / [INFO]	Command 0x07 received
+[2025-08-22 14:09:16:828] / [INFO]	Sample rate set to: 8000000
+[2025-08-22 14:09:16:829] / [INFO]	Pico device sample rate set
+[2025-08-22 14:09:16:829] / [INFO]	Request received
+[2025-08-22 14:09:16:830] / [INFO]	Command 0x06 received
+[2025-08-22 14:09:16:830] / [ERROR]	Invalid buffer size
+[2025-08-22 14:09:16:830] / [INFO]	Request received
+[2025-08-22 14:09:16:842] / [ERROR]	Client disconnected
+[2025-08-22 14:09:16:842] / [INFO]	Pico device stopped
+[2025-08-22 14:09:16:842] / [INFO]	Data sent to socket
+[2025-08-22 14:09:16:849] / [INFO]	Pico device closed
+[2025-08-22 14:09:16:850] / [ERROR]	Failed to send data to socket
+[2025-08-22 14:09:16:850] / [INFO]	Request processing finished
+[2025-08-22 14:09:16:850] / [INFO]	Wait for connection...

BIN
services/spectrometer/bin/picologs/pico-log-20250822-141057.txt


BIN
services/spectrometer/bin/picologs/pico-log-20250822-141229.txt


BIN
services/spectrometer/bin/picologs/pico-log-20250822-143438.txt


BIN
services/spectrometer/bin/picologs/pico-log-20250822-150026.txt


BIN
services/spectrometer/bin/picologs/pico-log-20250822-152732.txt


+ 3 - 0
services/spectrometer/bin/picologs/pico-log-20250827-121403.txt

@@ -0,0 +1,3 @@
+[2025-08-27 12:14:03:884] / [INFO]	Open socket
+[2025-08-27 12:14:03:885] / [INFO]	Socket initialized!
+[2025-08-27 12:14:03:885] / [INFO]	Wait for connection...

BIN
services/spectrometer/bin/picologs/pico-log-20250827-123005.txt


BIN
services/spectrometer/bin/picologs/pico-log-20250827-130006.txt


BIN
services/spectrometer/bin/picologs/pico-log-20250827-134103.txt


BIN
services/spectrometer/bin/picologs/pico-log-20250827-134238.txt


BIN
services/spectrometer/bin/ps4000a.dll


BIN
services/spectrometer/bin/ps4000a.lib


+ 15 - 0
services/spectrometer/docker-compose.yml

@@ -0,0 +1,15 @@
+services:
+  srv-hardware:
+    platform: windows/amd64
+    container_name: srv-hardware
+    build:
+      context: .
+      args:
+        APP_VERSION: ${APP_VERSION:-dev}
+    image: srv-hardware:${APP_VERSION:-dev}
+    environment:
+      SERVICE_PORT: ${SERVICE_PORT:-2456}
+      APP_VERSION: ${APP_VERSION:-dev}
+    ports:
+      - "${SERVICE_PORT:-2456}:${SERVICE_PORT:-2456}"
+    restart: unless-stopped

+ 22 - 0
services/spectrometer/manage.py

@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+    """Run administrative tasks."""
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mserv00.settings')
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError as exc:
+        raise ImportError(
+            "Couldn't import Django. Are you sure it's installed and "
+            "available on your PYTHONPATH environment variable? Did you "
+            "forget to activate a virtual environment?"
+        ) from exc
+    execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+    main()

+ 0 - 0
services/spectrometer/mserv00/__init__.py


+ 16 - 0
services/spectrometer/mserv00/asgi.py

@@ -0,0 +1,16 @@
+"""
+ASGI config for mserv00 project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mserv00.settings')
+
+application = get_asgi_application()

+ 144 - 0
services/spectrometer/mserv00/settings.py

@@ -0,0 +1,144 @@
+"""
+Django settings for mserv00 project.
+
+Generated by 'django-admin startproject' using Django 5.2.3.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.2/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/5.2/ref/settings/
+"""
+
+import os
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'django-insecure-89&_=swo6&=@z714%#k6oo$ayu-cn5yu9k@h$+bq^=kf$#f!c3'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = ['127.0.0.1', 'localhost']
+
+
+# Application definition
+
+INSTALLED_APPS = [
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+    'rest_framework',
+    'django_filters',
+    'spectrometer'
+]
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'mserv00.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+WSGI_APPLICATION = 'mserv00.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
+
+if os.getenv('USE_POSTGRES', '').lower() in {'1', 'true', 'yes'}:
+    DATABASES = {
+        'default': {
+            'ENGINE': 'django.db.backends.postgresql',
+            'NAME': os.getenv('POSTGRES_DB', 'specdata'),
+            'USER': os.getenv('POSTGRES_USER', 'specadmin'),
+            'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'specadmin'),
+            'HOST': os.getenv('POSTGRES_HOST', 'localhost'),
+            'PORT': os.getenv('POSTGRES_PORT', '5432'),
+        }
+    }
+else:
+    DATABASES = {
+        'default': {
+            'ENGINE': 'django.db.backends.sqlite3',
+            'NAME': BASE_DIR / 'db.sqlite3',
+        }
+    }
+
+
+# Password validation
+# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+    },
+]
+
+REST_FRAMEWORK = {
+    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
+    'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
+    'PAGE_SIZE': 10
+}
+
+# Internationalization
+# https://docs.djangoproject.com/en/5.2/topics/i18n/
+
+LANGUAGE_CODE = 'ru-ru'
+
+TIME_ZONE = 'Europe/Moscow'
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/5.2/howto/static-files/
+
+STATIC_URL = 'static/'
+STATIC_ROOT = BASE_DIR / 'static'
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

+ 38 - 0
services/spectrometer/mserv00/urls.py

@@ -0,0 +1,38 @@
+"""
+URL configuration for mserv00 project.
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/5.2/topics/http/urls/
+Examples:
+Function views
+    1. Add an import:  from my_app import views
+    2. Add a URL to urlpatterns:  path('', views.home, name='home')
+Class-based views
+    1. Add an import:  from other_app.views import Home
+    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
+Including another URLconf
+    1. Import the include() function: from django.urls import include, path
+    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
+"""
+from django.contrib import admin
+from django.urls import path
+from django.urls import include
+from rest_framework import routers
+
+from spectrometer import views
+
+router = routers.DefaultRouter()
+router.register(r'users', views.UserViewSet)
+router.register(r'devices', views.device_ViewSet)
+router.register(r'mparams', views.measurement_info_Viewset)
+router.register(r'mstate', views.state_Viewset)
+router.register(r'mdata', views.measurement_data_ViewSet)
+router.register(r'measure', views.measurement_ViewSet)
+
+urlpatterns = [
+    path('admin/', admin.site.urls),
+    path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
+    path('', include('spectrometer.urls')),
+    path('api/', include(router.urls))
+]
+

+ 16 - 0
services/spectrometer/mserv00/wsgi.py

@@ -0,0 +1,16 @@
+"""
+WSGI config for mserv00 project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mserv00.settings')
+
+application = get_wsgi_application()

+ 3 - 0
services/spectrometer/readme.txt

@@ -0,0 +1,3 @@
+cd D:\Projects\lowfield_mri_programs\spectrometer_service\mserv00
+$env:APP_VERSION = (Get-Content .\VERSION).Trim()
+docker compose up --build

+ 22 - 0
services/spectrometer/requirements.txt

@@ -0,0 +1,22 @@
+asgiref==3.8.1
+b64==0.4
+beautifulsoup4==4.13.4
+bs4==0.0.2
+cached_properties==0.7.4
+certifi==2025.6.15
+charset-normalizer==3.4.2
+Django==5.2.3
+django-filter==25.1
+djangorestframework==3.16.0
+drf-writable-nested==0.7.2
+idna==3.10
+lxml==6.0.0
+numpy==2.3.1
+pyserial==3.5
+requests==2.32.4
+soupsieve==2.7
+sqlparse==0.5.3
+typing_extensions==4.14.0
+tzdata==2025.2
+urllib3==2.5.0
+psycopg[binary]==3.3.3

+ 0 - 0
services/spectrometer/spectrometer/__init__.py


+ 16 - 0
services/spectrometer/spectrometer/admin.py

@@ -0,0 +1,16 @@
+from django.contrib import admin
+from .models import *
+# Register your models here.
+
+admin.site.register(device)
+admin.site.register(adc_params)
+admin.site.register(sync_params)
+admin.site.register(sdr_params)
+admin.site.register(gra_params)
+admin.site.register(measurement_info)
+admin.site.register(measurement)
+admin.site.register(state)
+admin.site.register(measurement_data)
+admin.site.register(channel_data)
+admin.site.register(device_state)
+

+ 6 - 0
services/spectrometer/spectrometer/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class SpectrometerConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'spectrometer'

+ 585 - 0
services/spectrometer/spectrometer/engine.py

@@ -0,0 +1,585 @@
+from os import times
+import struct
+from .interfaces import adc_default, gra_default, sdr_default, sync_default
+from . import models
+from . import serializers
+from multiprocessing import *
+import numpy as np
+from rest_framework import permissions, viewsets
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.parsers import JSONParser,ParseError
+from rest_framework import status
+from django.conf import settings
+import threading
+import subprocess
+import base64
+import time as tm
+
+class NewThread(threading.Thread):
+    def __init__(self, group=None, target=None, name=None, args=(), kwargs={}):
+        threading.Thread.__init__(self, group, target, name, args, kwargs)
+        self._return = None
+
+    def run(self):
+        if self._target != None:
+            self._return = self._target(*self._args, **self._kwargs)
+        else:
+            self._return = ((0x00, 0), 0)
+
+    def join(self, *args):
+        threading.Thread.join(self, *args)
+        return self._return
+
+class DefaultEngine:
+    def __init__(self, instance, lock):
+        self.measure = instance
+        if(self.measure.info.iadc.enabled):
+            if(self.measure.info.iadc.device.proto == 'USB'):
+                self.adc_interface = adc_default(port=-1)
+            else:
+                self.adc_interface = adc_default(port=5003)
+
+        if(self.measure.info.isync.device.proto == 'USB'):
+            self.sync_interface = sync_default(port=-1)
+        else:
+            self.sync_interface = sync_default(port=5001)
+
+        if(self.measure.info.isdr.device.proto == 'USB'):
+            self.sdr_interface = sdr_default(port=-1)
+        else:
+            self.sdr_interface = sdr_default(port=5003)
+
+        if(self.measure.info.igrax.enabled):
+            self.grax_interface = gra_default(ip = self.measure.info.igrax.ip, port=5002)
+        if(self.measure.info.igray.enabled):
+            self.gray_interface = gra_default(ip = self.measure.info.igray.ip, port=5002)
+        if(self.measure.info.igraz.enabled):
+            self.graz_interface = gra_default(ip = self.measure.info.igraz.ip, port=5002)
+
+        self.engine_thread = threading.Thread(target=self.run)
+        self.lock = lock
+        self.engine_thread.start()
+        self.measure.state.status = 'STARTING'
+        self.lock.acquire()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+
+    def getThread(self):
+        return self.engine_thread
+    
+    def syncUp(self):
+        ret = self.sync_interface.upload(str(settings.BASE_DIR) + '\\bin\\Sync.exe',
+                                        self.measure.info.isync.file,
+                                        self.measure.info.isync.port)
+        
+        if(ret != 0):
+            self.measure.state.grax.status = 'CONFIG ERROR'
+            self.measure.state.grax.code = -1
+            self.measure.state.status = 'SYNC ERROR'
+            self.measure.state.code = -1
+            self.lock.acquire()
+            self.measure.state.grax.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            return -1
+
+        # Final configuration: set the status to configured for sync and overall.
+        self.measure.state.sync.status = 'CONFIGURED'
+        self.measure.state.sync.code = 1
+        self.measure.state.status = 'SYNC CONFIGURED'
+        self.measure.state.code = 1
+
+        self.lock.acquire()
+        self.measure.state.sync.save()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+        print('UPDATED')
+        return 0
+
+    def syncWait(self):
+        ret = self.sync_interface.trig_wait(self.measure.info.isync.port)
+        if(ret[0] == 0xFF):
+            self.measure.state.sync.status = 'TRIG SET ERROR'
+            self.measure.state.sync.code = -4
+            self.measure.state.status = 'SYNC ERROR'
+            self.measure.state.code = -1
+            self.lock.acquire()
+            self.measure.state.sync.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            return -1
+        self.measure.state.sync.status = 'WAIT FOR TRIGGER'
+        self.measure.state.sync.code = 2
+        self.measure.state.status = 'SYNC WAIT'
+        self.measure.state.code = 2
+        print('SENDED')
+        return 0
+
+    def syncDown(self):
+        ret = self.sync_interface.serial_close()
+        if(self.measure.info.iadc.enabled):
+            self.adc_interface.client_socket.close()
+        self.measure.state.sync.status = 'TRIGGERING COMPLETE'
+        self.measure.state.sync.code = 0
+        return 0
+        
+    def graUp(self):
+        if(self.measure.info.igrax.enabled == True):
+            try:
+                self.grax_interface.connect()
+            except Exception as e:
+                self.measure.state.grax.status = 'CONN ERROR'
+                self.measure.state.grax.code = -1
+                self.measure.state.status = 'GRA X ERROR'
+                self.measure.state.code = -2
+                self.lock.acquire()
+                self.measure.state.grax.save()
+                self.measure.state.save()
+                self.measure.save()
+                self.lock.release()
+                return -1
+            
+            # Reset and power on
+            self.grax_interface.reset()
+            ret = self.grax_interface.state()
+            self.measure.state.grax.code = ret[2]
+            self.measure.state.grax.status = f'RETCODE {ret}'
+
+            # Power on
+            self.grax_interface.ps_on()
+            ret = self.grax_interface.state()
+            self.measure.state.grax.code = ret[2]
+            self.measure.state.grax.status = f'RETCODE {ret}'
+
+            data = b''
+            with open(self.measure.info.igrax.file, 'rb') as file:
+                data = file.read()
+            i = 0
+            nodes = []
+            while(i < len(nodes)):
+                time, ampl = struct.unpack('<IH', data[i:])
+                node = []
+                node.append(time)
+                node.append(ampl)
+                nodes.append(node)
+                i += 6
+            points = len(nodes)
+            self.grax_interface.upload(points, nodes)
+            ret = self.grax_interface.state()
+            self.measure.state.grax.code = ret[2]
+            self.measure.state.grax.status = f'RETCODE {ret}'
+
+            self.grax_interface.ps_off()
+            ret = self.grax_interface.state()
+            self.measure.state.grax.code = ret[2]
+            self.measure.state.grax.status = f'RETCODE {ret}'
+
+        self.measure.state.grax.code = 1
+        self.measure.state.grax.status = 'CONFIGURED'
+        self.lock.acquire()
+        self.measure.state.grax.save()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+
+        if(self.measure.info.igray.enabled == True):
+            try:
+                self.gray_interface.connect()
+            except Exception as e:
+                self.measure.state.gray.status = 'CONN ERROR'
+                self.measure.state.gray.code = -1
+                self.measure.state.status = 'GRA Y ERROR'
+                self.measure.state.code = -2
+                self.lock.acquire()
+                self.measure.state.gray.save()
+                self.measure.state.save()
+                self.measure.save()
+                self.lock.release()
+                return -1
+            
+            self.gray_interface.reset()
+            ret = self.gray_interface.state()
+            self.measure.state.gray.code = ret[2]
+            self.measure.state.gray.status = f'RETCODE {ret}'
+
+            self.gray_interface.ps_on()
+            ret = self.gray_interface.state()
+            self.measure.state.gray.code = ret[2]
+            self.measure.state.gray.status = f'RETCODE {ret}'
+
+            data = b''
+            with open(self.measure.info.igray.file, 'rb') as file:
+                data = file.read()
+            i = 0
+            nodes = []
+            while(i < len(nodes)):
+                time, ampl = struct.unpack('<IH', data[i:])
+                node = []
+                node.append(time)
+                node.append(ampl)
+                nodes.append(node)
+                i += 6
+            points = len(nodes)
+            self.gray_interface.upload(points, nodes)
+            ret = self.gray_interface.state()
+            self.measure.state.gray.code = ret[2]
+            self.measure.state.gray.status = f'RETCODE {ret}'
+
+            self.gray_interface.ps_off()
+            ret = self.gray_interface.state()
+            self.measure.state.gray.code = ret[2]
+            self.measure.state.gray.status = f'RETCODE {ret}'
+
+        self.measure.state.gray.code = 1
+        self.measure.state.gray.status = 'CONFIGURED'
+        self.lock.acquire()
+        self.measure.state.gray.save()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+
+        if(self.measure.info.igraz.enabled == True):
+            try:
+                self.graz_interface.connect()
+            except Exception as e:
+                self.measure.state.graz.status = 'CONN ERROR'
+                self.measure.state.graz.code = -1
+                self.measure.state.status = 'GRA Z ERROR'
+                self.measure.state.code = -2
+                self.lock.acquire()
+                self.measure.state.graz.save()
+                self.measure.state.save()
+                self.measure.save()
+                self.lock.release()
+                return -1
+            
+            self.graz_interface.reset()
+            ret = self.graz_interface.state()
+            self.measure.state.graz.code = ret[2]
+            self.measure.state.graz.status = f'RETCODE {ret}'
+
+            self.graz_interface.ps_on()
+            ret = self.graz_interface.state()
+            self.measure.state.graz.code = ret[2]
+            self.measure.state.graz.status = f'RETCODE {ret}'
+
+            data = b''
+            with open(self.measure.info.igraz.file, 'rb') as file:
+                data = file.read()
+            i = 0
+            nodes = []
+            while(i < len(nodes)):
+                time, ampl = struct.unpack('<IH', data[i:])
+                node = []
+                node.append(time)
+                node.append(ampl)
+                nodes.append(node)
+                i += 6
+            points = len(nodes)
+            self.graz_interface.upload(points, nodes)
+            ret = self.graz_interface.state()
+            self.measure.state.graz.code = ret[2]
+            self.measure.state.graz.status = f'RETCODE {ret}'
+
+            self.graz_interface.ps_off()
+            ret = self.graz_interface.state()
+            self.measure.state.graz.code = ret[2]
+            self.measure.state.graz.status = f'RETCODE {ret}'
+        
+        self.measure.state.graz.code = 1
+        self.measure.state.graz.status = 'CONFIGURED'
+        self.measure.state.status = 'GRA CONFIGURED'
+        self.measure.state.code = 2
+        
+        self.lock.acquire()
+        self.measure.state.graz.save()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+        return 0
+
+    def adcUp(self):
+        try:
+            self.adc_interface.connect()
+        except:
+            self.measure.state.adc.status = 'CONN ERROR'
+            self.measure.state.adc.code = -1
+            self.measure.state.status = 'ADC ERROR'
+            self.measure.state.code = -3
+            self.lock.acquire()
+            self.measure.state.adc.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            self.adc_interface.client_socket.close()
+            return -1
+        
+        ret = self.adc_interface.open()
+        if(ret[0] == 0xFF):
+            self.measure.state.adc.status = 'OPEN ERROR'
+            self.measure.state.adc.code = -2
+            self.measure.state.status = 'ADC ERROR'
+            self.measure.state.code = -3
+            self.lock.acquire()
+            self.measure.state.adc.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            self.adc_interface.client_socket.close()
+            return ret
+        self.measure.state.adc.status = 'OPENED'
+        self.measure.state.adc.code = 1
+        self.measure.state.status = 'ADC OPENED'
+        self.measure.state.code = 3
+        self.lock.acquire()
+        self.measure.state.sync.save()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+        print('UPDATED')
+        return 0
+
+    def adcConfig(self):
+        rate = self.measure.info.iadc.srate
+        ret = self.adc_interface.set_rate(rate)
+        if(ret[0] == 0xFF):
+            self.measure.state.adc.status = 'RATE ERROR'
+            self.measure.state.adc.code = -3
+            self.measure.state.status = 'ADC ERROR'
+            self.measure.state.code = -3
+            self.lock.acquire()
+            self.measure.state.adc.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            self.adc_interface.client_socket.close()
+            return ret
+        
+        points = self.measure.info.iadc.points
+        ret = self.adc_interface.set_points(points)
+        if(ret[0] == 0xFF):
+            self.measure.state.adc.status = 'POINTS ERROR'
+            self.measure.state.adc.code = -4
+            self.measure.state.status = 'ADC ERROR'
+            self.measure.state.code = -3
+            self.lock.acquire()
+            self.measure.state.adc.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            self.adc_interface.client_socket.close()
+            return ret
+        
+        ret = self.adc_interface.config_channels(self.measure.info.iadc.n_channels,
+                                                self.measure.info.iadc.channel_ranges,
+                                                self.measure.info.iadc.trigger_channel, 
+                                                self.measure.info.iadc.trig_direction,
+                                                self.measure.info.iadc.threshold,
+                                                self.measure.info.iadc.auto_measure_time)
+        if(ret[0] == 0xFF):
+            self.measure.state.adc.status = 'TRIGGER CONFIG ERROR'
+            self.measure.state.adc.code = -5
+            self.measure.state.status = 'ADC ERROR'
+            self.measure.state.code = -3
+            self.lock.acquire()
+            self.measure.state.adc.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            self.adc_interface.client_socket.close()
+            return ret
+        
+        ret = self.adc_interface.set_premeasure(0)
+        if(ret[0] == 0xFF):
+            self.measure.state.adc.status = 'SET PREMEASURE ERROR'
+            self.measure.state.adc.code = -6
+            self.measure.state.status = 'ADC ERROR'
+            self.measure.state.code = -3
+            self.lock.acquire()
+            self.measure.state.adc.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            self.adc_interface.client_socket.close()
+            return ret
+        
+        ret = self.adc_interface.set_trignum(self.measure.info.iadc.n_triggers)
+        if(ret[0] == 0xFF):
+            self.measure.state.adc.status = 'SET PREMEASURE ERROR'
+            self.measure.state.adc.code = -6
+            self.measure.state.status = 'ADC ERROR'
+            self.measure.state.code = -3
+            self.lock.acquire()
+            self.measure.state.adc.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            self.adc_interface.client_socket.close()
+            return ret
+        self.measure.state.adc.status = 'CONFIGURED'
+        self.measure.state.adc.code = 2
+        self.measure.state.status = 'ADC CONFIGURED'
+        self.measure.state.code = 3
+        self.lock.acquire()
+        self.measure.state.sync.save()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+        print('UPDATED')
+        return 0
+
+    def sdrUp(self, thr):
+        ret = self.sdr_interface.transf(str(settings.BASE_DIR) + '\\bin\\hackrftrans00.exe',
+                                        self.measure.info.isdr.file,
+                                        self.measure.info.isdr.freq,
+                                        self.measure.info.isdr.srate,
+                                        int(self.measure.info.isdr.ampl),
+                                        self.measure.info.isdr.gain)
+        if(ret[0] == 0xFF):
+            self.measure.state.sdr.status = 'SDR ERROR (WAIT FOR STOPING ADC!)'
+            self.measure.state.sdr.code = -1
+            self.measure.state.status = 'SDR ERROR'
+            self.measure.state.code = -3
+            self.lock.acquire()
+            self.measure.state.sdr.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            if(thr != None):
+                thr.join()
+            self.measure.state.sdr.status = 'SDR ERROR'
+            self.measure.state.sdr.code = -2
+            self.lock.acquire()
+            self.measure.state.sdr.save()
+            self.measure.state.save()
+            self.measure.save()
+            self.lock.release()
+            self.adc_interface.client_socket.close()
+            return ret
+        self.measure.state.sdr.status = 'STARTED'
+        self.measure.state.sdr.code = 1
+        self.measure.state.status = 'SDR START'
+        self.measure.state.code = 4
+        self.lock.acquire()
+        self.measure.state.sdr.save()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+        return 0
+
+    def adcWrite(self, k):
+        self.lock.acquire()
+        for i in range(len(self.adc_interface.ndata)):
+            data = models.measurement_data.objects.create(data_num=i, averaging_num=k, measurement=self.measure)
+            for j in range(len(self.adc_interface.ndata[i])):
+                cdata = data.channel_data.create(channel_num=j,
+                                        measurement_data=data,
+                                        channel_data=base64.b64encode(self.adc_interface.ndata[i][j]).decode('utf-8'))
+                cdata.save()
+                data.save()
+        self.measure.save()
+        self.lock.release()
+        self.adc_interface.clear_ndata()
+        tm.sleep(1)
+        return 0
+    
+    def close(self):
+        ret = self.sync_interface.serial_close()
+        if(self.measure.info.iadc.enabled):
+            self.adc_interface.client_socket.close()
+
+        self.measure.state.adc.status = 'MEASURE COMPLETED'
+        self.measure.state.data_ready = True
+        self.measure.state.adc.code = 1
+        self.measure.state.status = 'MEASURE COMPLETED'
+        self.measure.state.code = 1
+
+        self.lock.acquire()
+        self.measure.state.grax.save()
+        self.measure.state.gray.save()
+        self.measure.state.graz.save()
+        self.measure.state.sync.save()
+        self.measure.state.sdr.save()
+        self.measure.state.adc.save()
+        self.measure.state.save()
+        self.measure.save()
+        self.lock.release()
+        return 0
+
+    def run(self):
+        # Connect to the sync product first
+        # Open the sync product, check for errors if any returned with status byte 0xFF
+        self.syncUp()
+        self.graUp()
+
+        self.sync_interface.serial_open(self.measure.info.isync.port)
+
+        if(self.measure.info.iadc.enabled == True):
+            for k in range(0, self.measure.info.iadc.averaging):
+                print('SENDING TRIGGER')
+
+                # Set trigger wait, check for errors
+                ret = self.syncWait()
+                if(ret != 0):
+                    return ret
+                
+                ret = self.adcUp()
+                if(ret != 0):
+                    return ret
+                
+                ret = self.adcConfig()
+                if(ret != 0):
+                    return ret
+                
+                t = NewThread(target=self.adc_interface.start)
+                t.start()
+
+                tm.sleep(1)
+
+                ret = self.sdrUp(t)
+                
+                t.join()
+
+                ret = self.adc_interface.measure_code
+                if(ret[0] == 0xFF):
+                    self.measure.state.adc.status = 'MEASURE ERROR'
+                    self.measure.state.adc.code = -7
+                    self.measure.state.status = 'ADC ERROR'
+                    self.measure.state.code = -3
+                    self.lock.acquire()
+                    self.measure.state.adc.save()
+                    self.measure.state.save()
+                    self.measure.save()
+                    self.lock.release()
+                    self.adc_interface.client_socket.close()
+                    return ret
+
+                self.adcWrite(k)
+        else:
+            print('SENDING TRIGGER')
+            # Set trigger wait, check for errors
+            ret = self.syncWait()
+            if(ret != 0):
+                return ret
+
+            ret = self.sdrUp(None)
+            if(ret[0] != 0):
+                return ret
+            
+
+        ret = self.close()
+        if(ret != 0):
+            return ret
+
+        return 0
+
+EngineDict = {
+    'DefaultEngine': DefaultEngine
+}
+        
+

+ 343 - 0
services/spectrometer/spectrometer/interfaces.py

@@ -0,0 +1,343 @@
+import socket
+import struct
+import subprocess
+import numpy as np
+import serial
+import time as tm
+
+probe = {
+        '10mV': np.int8(0),
+        '20mV': np.int8(1), 
+        '50mV': np.int8(2), 
+        '100mV': np.int8(3), 
+        '200mV': np.int8(4), 
+        '500mV': np.int8(5), 
+        '1V': np.int8(6), 
+        '2V': np.int8(7), 
+        '5V': np.int8(8),
+        '10V': np.int8(9), 
+        '20V': np.int8(10), 
+        '50V': np.int8(11), 
+        '100V': np.int8(12), 
+        '200V': np.int8(13)
+        }
+
+class adc_default:
+    def __init__(self, port):
+        self.port = port
+        self.ndata = []
+        self.channels_data = []
+        self.measure_code = (0x00, 0)
+
+        self.proto = {
+            'open': 0x01,
+            'set_rate': 0x07,
+            'set_points': 0x06,
+            'config_channels': 0x09,
+            'set_premeasure': 0x28,
+            'set_trignum': 0x19,
+            'start': 0x3B,
+            'stop': 0x03,
+        }
+
+    def connect(self, port=0):
+        self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.client_socket.settimeout(10.0)
+        resp = self.client_socket.connect(('127.0.0.1', self.port))
+        return resp
+
+    def resp_handler(self, resp, cmd):
+        if(resp[0] != 0xAA):
+            return (-1, 0)
+        if(resp[1] == 0xFF):
+            return (0xFF, resp[3])
+        return (resp[1], 0)
+            
+    def open(self):
+        msg = struct.pack('<BB', 0xAA, self.proto['open'])
+        self.client_socket.send(msg)
+        resp = self.client_socket.recv(4096)
+        return self.resp_handler(resp, self.proto['open'])
+    
+    def set_rate(self, rate):
+        print('send sample_rate')
+        msg = struct.pack('<BBI', 0xAA, self.proto['set_rate'], np.uint32(rate))
+        self.client_socket.send(msg)
+        resp = self.client_socket.recv(4096)
+        return resp
+
+    def set_points(self, points):
+        print('send points')
+        msg = struct.pack('<BBI', 0xAA, self.proto['set_points'], len(points))
+        for p in points:
+            msg += p.to_bytes(length=4, byteorder='little')
+        self.client_socket.send(msg)
+        resp = self.client_socket.recv(4096)
+        return self.resp_handler(resp, self.proto['set_points'])
+
+    def config_channels(self, nchannels, channel_ranges, trig_channel, trig_dirrection, trig_threshold, auto_trigger_time):
+        print('configure')
+        print(channel_ranges)
+        channel_ranges_bytes = b''
+        for r in channel_ranges:
+            channel_ranges_bytes += r.to_bytes(length=1)
+        print(channel_ranges_bytes)
+
+        msg = struct.pack(f'<BBI{nchannels}sBiHh', 0xAA, self.proto['config_channels'], nchannels, channel_ranges_bytes, trig_channel, trig_dirrection, trig_threshold, auto_trigger_time)
+        print(msg[6:8])
+        self.client_socket.send(msg)
+        resp = self.client_socket.recv(4096)
+        return self.resp_handler(resp, self.proto['config_channels'])
+
+    def set_premeasure(self, trig_premeasure):
+        print('set pre-measure')
+        msg = struct.pack('<BBI', 0xAA, self.proto['set_premeasure'], trig_premeasure)
+        self.client_socket.send(msg)
+        resp = self.client_socket.recv(4096)
+        return self.resp_handler(resp, self.proto['set_premeasure'])
+
+    def set_trignum(self, trig_num):
+        print('set trignum')
+        msg = struct.pack('<BBI', 0xAA, self.proto['set_trignum'], trig_num)
+        self.client_socket.send(msg)
+        resp = self.client_socket.recv(4096)
+        return self.resp_handler(resp, self.proto['set_trignum'])
+    
+    def start(self):
+        data = b''
+        temp = b''
+        print('start')
+        msg = struct.pack('<BB', 0xAA, self.proto['start'])
+        self.client_socket.send(msg)
+        resp = b'\x00\x00'
+        resp = self.client_socket.recv(4096)
+        if(resp[1] == 0xFB):
+            try:
+                print(f"Data confirmed with code {resp[1]}")
+                msg = struct.pack('<BB', 0xAA, 0xCB)
+                self.client_socket.send(msg)
+            except TimeoutError as e:
+                print("Timeout!")
+                resp = b'\x00\x00'
+        else:
+            print('error')
+            return (self.resp_handler(resp, self.proto['start']), self.ndata)
+        
+        nsignal = 0
+        nchannel = 0
+        last_signal = 0
+        last_channel = 0
+        resp = self.client_socket.recv(4096)
+        while(resp[1] != 0xFC):
+            if(resp[1] == 0xFF):
+                self.measure_code = self.resp_handler(resp, self.proto['start'])
+                print('error')
+                return (self.resp_handler(resp, self.proto['start']), self.ndata)
+            elif(resp[1] == 0xFD):
+                magic, cmd, nsignal, nchannel, total_packets, npacket, ndata_sent, first = struct.unpack(f'<BBIIIIIh', resp[0:24])
+                eod = ndata_sent+22
+                print(f'sig {nsignal}, ch {nchannel}, packet {npacket} / {total_packets}')
+                temp = resp[22:eod]
+                #print(f'First: {first} or First: {struct.unpack('<h', temp[0:2])}')
+                msg = struct.pack('<BB', 0xAA, 0x3D)
+                self.client_socket.send(msg)
+
+                if(nchannel > last_channel):
+                    print('append channel')
+                    self.channels_data.append(data)
+                    data = b''
+                    last_channel += 1
+                if(nsignal > last_signal):
+                    print('append channel')
+                    self.channels_data.append(data)
+                    data = b''
+                    last_channel = 0
+                    last_signal += 1
+                    print('append data')
+                    self.ndata.append(self.channels_data)
+                    self.channels_data = []
+                data += temp
+            resp = self.client_socket.recv(4096)
+            print(f'RESP: {resp[1]}')
+        msg = struct.pack('<BB', 0xAA, 0x3C)
+        self.client_socket.send(msg)
+        print('append channel')
+        self.channels_data.append(data)
+        print('append data')
+        self.ndata.append(self.channels_data)
+        self.measure_code = self.resp_handler(resp, self.proto['start'])
+        return (self.resp_handler(resp, self.proto['start']), self.ndata)
+    
+    def stop(self):
+        print('set post-measure')
+        msg = struct.pack('<BB', 0xAA, self.proto['stop'])
+        self.client_socket.send(msg)
+        #resp = self.client_socket.recv(4096)
+        return self.resp_handler((0x00, 0x00), self.proto['stop'])
+    
+    def clear_ndata(self):
+        self.ndata = []
+        self.channels_data = []
+
+    
+class sync_default:
+    def __init__(self, port):
+        if(port != -1):
+            self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+
+        self.proto = {
+            'upload': 0x01,
+            'trig_wait': 0x02
+        }
+        
+        self.serial = None
+
+    def connect(self, port):
+        self.client_socket.settimeout(5.0)
+        resp = self.client_socket.connect(('localhost', port))
+        return resp
+
+    def resp_handler(self, resp, cmd):
+        if(resp[0] != 0xAA):
+            return (-1, 0)
+        if(resp[1] == 0xFF):
+            return (0xFF, resp[3])
+        return (resp[1], 0)
+            
+    def upload(self, programPath, filePath, port):
+        try:
+            process = subprocess.run([programPath, filePath, '-p', str(port), '--debug'])
+        except subprocess.CalledProcessError as e:
+            print(f"Ошибка команды {e.cmd}!")
+        return process.returncode
+    
+    def serial_open(self, port):
+        try:
+            self.serial = serial.Serial('COM' + str(port), 9600, timeout=1)
+        except serial.SerialException as e:
+            print(e)
+            return (0xFF, -1)
+        return(0x00, 0)
+
+    def trig_wait(self, port):
+        try:
+            self.serial.write(b'e')
+        except serial.SerialException as e:
+            print(e)
+            return (0xFF, -1)
+        return(0x00, 0)
+
+    def serial_close(self):
+        try:
+            self.serial.close()
+        except:
+            return (0xFF, -1)
+        return (0x00, 0)
+
+class sdr_default:
+    def __init__(self, port):
+        if(port != -1):
+            self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+
+        self.proto = {
+            'transf': 0x01
+        }
+
+    def connect(self, port):
+        self.client_socket.settimeout(5.0)
+        resp = self.client_socket.connect(('localhost', port))
+        return resp
+
+    def transf(self, programPath, filePath, freq, rate, ampl, gain):
+        try:
+            process = subprocess.run([programPath, '-t', filePath, '-f', str(freq), '-s', str(rate), '-a', str(ampl), '-x', str(gain)], check=True)
+        except subprocess.CalledProcessError as e:
+            print(f"Ошибка команды {e.cmd}!")
+            return (0xFF, -1)
+        return (0x00, process.returncode)
+    
+class gra_default:
+    def __init__(self, ip, port):
+        if(port != -1):
+            self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            self.client_socket.settimeout(5.0)
+            self.client_socket.connect((ip, port))
+
+        self.proto = {
+            'reset': 0x0001,
+            'ps_on': 0x0003,
+            'ps_off': 0x0004,
+            'state': 0x0005,
+            'upload': 0x0006,
+            'send_packet': 0x0007,
+            'confirm': 0x0008
+        }
+
+    def connect(self, port):
+        self.client_socket.settimeout(5.0)
+        resp = self.client_socket.connect(('localhost', port))
+        return resp
+
+    def reset(self):
+        msg = struct.pack('<HH', 0xAAAA, self.proto['reset'])
+        self.client_socket.send(msg)
+
+    def ps_on(self):
+        msg = struct.pack('<HH', 0xAAAA, self.proto['ps_on'])
+        self.client_socket.send(msg)
+
+    def ps_off(self):
+        msg = struct.pack('<HH', 0xAAAA, self.proto['ps_off'])
+        self.client_socket.send(msg)
+
+    def ps_off(self):
+        msg = struct.pack('<HH', 0xAAAA, self.proto['ps_off'])
+        self.client_socket.send(msg)
+
+    def state(self):
+        msg = struct.pack('<HH', 0xAAAA, self.proto['state'])
+        self.client_socket.send(msg)
+        resp = self.client_socket.recv(4096)
+        if(len(resp) == 6):
+            magic, cmd, state = struct.unpack('<HHH', resp)
+            return (cmd, state, 0, 0)
+        elif(len(resp) > 6):
+            magic, cmd, state, errbits, errpoint, current_setting_amp, current_amp, first_sensor, second_sensor = struct.unpack('<HHHIIiiii', resp)
+            return (cmd, state, errbits, errpoint)
+        
+    def send_packet(self, nom, nodes_num, nodes_buf):
+        i = 0
+        time1, time2 = struct.unpack('<HH', nodes_buf[i][0].to_bytes(4, 'little'))
+        nom = nom | 0x8000
+        msg = struct.pack('<HHHHHHH', 0xAAAA, self.proto['send_packet'], nom, nodes_num, time1, time2, nodes_buf[i][1].to_bytes(2, 'little'))
+        while(i < nodes_num):
+            i += 1
+            time1, time2 = struct.unpack('<HH', nodes_buf[i][0].to_bytes(4, 'little'))
+            msg += struct.pack('<HHH', time1, time2, nodes_buf[i][1].to_bytes(2, 'little'))
+        self.client_socket.send(msg)
+        resp = self.client_socket.recv(4096)
+        data_resp = struct.unpack('<HHH', resp)
+        return data_resp[3]
+    
+    def upload(self, points, nodes):
+        points1, points2 = struct.unpack('<HH', points.to_bytes(4, 'little'))
+        nodes1, nodes2 = struct.unpack('<HH', nodes[points-1][0].to_bytes(4, 'little'))
+        msg = struct.pack('<HHHHHH', 0xAAAA, self.proto['upload'], points1, points2, nodes1, nodes2)
+        self.client_socket.send(msg)
+        seg_num = 0
+        total_packets = points // 200 + 1
+        first_idx = 0
+        while(seg_num < total_packets):
+            if((points - first_idx) < 200):
+                nodes_packet = points - first_idx
+            else:
+                nodes_packet = 200
+            last_idx = first_idx + nodes_packet
+            resp = self.send_packet(seg_num, nodes_packet, nodes[first_idx:last_idx])
+            print(resp)
+            first_idx = last_idx
+            seg_num += 1
+        msg = struct.pack('<HH', 0xAAAA, 0x0008)
+        self.client_socket.send(msg)
+
+    

+ 156 - 0
services/spectrometer/spectrometer/migrations/0001_initial.py

@@ -0,0 +1,156 @@
+# Generated by Django 5.2.3 on 2025-07-15 08:45
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='device',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('device_type', models.CharField(default='ADC', max_length=100)),
+                ('brend', models.CharField(default='Picoscope', max_length=100)),
+                ('serial_model', models.CharField(default='PS400A', max_length=100)),
+                ('proto', models.CharField(default='TCP', max_length=100)),
+                ('proto_interface', models.CharField(default='adc_default', max_length=100)),
+                ('time_creation', models.DateTimeField(default=django.utils.timezone.now)),
+                ('time_publication', models.DateTimeField(blank=True, null=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='device_state',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('status', models.CharField(default='', max_length=100)),
+                ('code', models.BigIntegerField(default=0)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='measurement',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='adc_params',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('srate', models.PositiveIntegerField()),
+                ('points', models.JSONField(blank=True, default=list, help_text='Массив целых чисел в формате JSON', null=True)),
+                ('n_channels', models.PositiveIntegerField()),
+                ('channel_ranges', models.JSONField(blank=True, default=list, help_text='Массив целых чисел в формате JSON', null=True)),
+                ('n_triggers', models.PositiveIntegerField()),
+                ('trigger_channel', models.PositiveIntegerField()),
+                ('threshold', models.IntegerField()),
+                ('trig_direction', models.IntegerField()),
+                ('auto_measure_time', models.PositiveIntegerField()),
+                ('enabled', models.BooleanField(default=True)),
+                ('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='adc_params', to='spectrometer.device')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='gra_params',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('ip', models.GenericIPAddressField(default='127.0.0.1')),
+                ('file', models.CharField()),
+                ('enabled', models.BooleanField(default=True)),
+                ('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='gra_params', to='spectrometer.device')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='measurement_data',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('data_num', models.IntegerField(default=0)),
+                ('measurement', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='measurement_data1', to='spectrometer.measurement')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='channel_data',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('channel_num', models.IntegerField(default=0)),
+                ('channel_data', models.TextField(default='')),
+                ('measurement_data', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='measurement_data1', to='spectrometer.measurement_data')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='measurement_info',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('infostr', models.CharField(default='str', max_length=100)),
+                ('time', models.DateTimeField(default=django.utils.timezone.now)),
+                ('iadc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='measurement_info6', to='spectrometer.adc_params')),
+                ('igrax', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='measurement_info3', to='spectrometer.gra_params')),
+                ('igray', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='measurement_info2', to='spectrometer.gra_params')),
+                ('igraz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='measurement_info1', to='spectrometer.gra_params')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='measurement',
+            name='info',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='measurement2', to='spectrometer.measurement_info'),
+        ),
+        migrations.CreateModel(
+            name='sdr_params',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('srate', models.PositiveIntegerField(default=2000000)),
+                ('freq', models.PositiveIntegerField(default=3000000)),
+                ('gain', models.PositiveIntegerField(default=35)),
+                ('ampl', models.BooleanField(default=True)),
+                ('file', models.CharField()),
+                ('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sdr_params', to='spectrometer.device')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='measurement_info',
+            name='isdr',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='measurement_info4', to='spectrometer.sdr_params'),
+        ),
+        migrations.CreateModel(
+            name='state',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('id_measurement', models.IntegerField(default=0)),
+                ('status', models.CharField(default='default', max_length=100)),
+                ('code', models.BigIntegerField(default=0)),
+                ('data_ready', models.BooleanField(default=False)),
+                ('adc', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state6', to='spectrometer.device_state')),
+                ('grax', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state3', to='spectrometer.device_state')),
+                ('gray', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state2', to='spectrometer.device_state')),
+                ('graz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state1', to='spectrometer.device_state')),
+                ('sdr', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state4', to='spectrometer.device_state')),
+                ('sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state5', to='spectrometer.device_state')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='measurement',
+            name='state',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='measurement1', to='spectrometer.state'),
+        ),
+        migrations.CreateModel(
+            name='sync_params',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('file', models.CharField(default='Sync_params.xml', max_length=100)),
+                ('port', models.IntegerField(default=7)),
+                ('device', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sync_params', to='spectrometer.device')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='measurement_info',
+            name='isync',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='measurement_info5', to='spectrometer.sync_params'),
+        ),
+    ]

+ 18 - 0
services/spectrometer/spectrometer/migrations/0002_measurement_data_channel_data.py

@@ -0,0 +1,18 @@
+# Generated by Django 5.2.3 on 2025-07-15 08:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('spectrometer', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='measurement_data',
+            name='channel_data',
+            field=models.ManyToManyField(related_name='measurement_data1', to='spectrometer.channel_data'),
+        ),
+    ]

+ 17 - 0
services/spectrometer/spectrometer/migrations/0003_remove_channel_data_measurement_data.py

@@ -0,0 +1,17 @@
+# Generated by Django 5.2.3 on 2025-07-15 08:58
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('spectrometer', '0002_measurement_data_channel_data'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='channel_data',
+            name='measurement_data',
+        ),
+    ]

+ 18 - 0
services/spectrometer/spectrometer/migrations/0004_state_engine.py

@@ -0,0 +1,18 @@
+# Generated by Django 5.2.3 on 2025-07-15 10:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('spectrometer', '0003_remove_channel_data_measurement_data'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='state',
+            name='engine',
+            field=models.CharField(default='DefaultEngine', max_length=100),
+        ),
+    ]

+ 22 - 0
services/spectrometer/spectrometer/migrations/0005_remove_state_engine_measurement_info_engine.py

@@ -0,0 +1,22 @@
+# Generated by Django 5.2.3 on 2025-07-16 09:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('spectrometer', '0004_state_engine'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='state',
+            name='engine',
+        ),
+        migrations.AddField(
+            model_name='measurement_info',
+            name='engine',
+            field=models.CharField(default='DefaultEngine', max_length=100),
+        ),
+    ]

+ 24 - 0
services/spectrometer/spectrometer/migrations/0006_channel_data_measurement_data_and_more.py

@@ -0,0 +1,24 @@
+# Generated by Django 5.2.3 on 2025-07-17 15:49
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('spectrometer', '0005_remove_state_engine_measurement_info_engine'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='channel_data',
+            name='measurement_data',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='channel_data1', to='spectrometer.measurement_data'),
+        ),
+        migrations.AlterField(
+            model_name='measurement_data',
+            name='channel_data',
+            field=models.ManyToManyField(related_name='measurement_data2', to='spectrometer.channel_data'),
+        ),
+    ]

+ 23 - 0
services/spectrometer/spectrometer/migrations/0007_adc_params_averaging_measurement_data_averaging_num.py

@@ -0,0 +1,23 @@
+# Generated by Django 5.2.3 on 2025-07-22 15:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('spectrometer', '0006_channel_data_measurement_data_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='adc_params',
+            name='averaging',
+            field=models.PositiveIntegerField(default=1),
+        ),
+        migrations.AddField(
+            model_name='measurement_data',
+            name='averaging_num',
+            field=models.IntegerField(default=0),
+        ),
+    ]

+ 0 - 0
services/spectrometer/spectrometer/migrations/__init__.py


+ 136 - 0
services/spectrometer/spectrometer/models.py

@@ -0,0 +1,136 @@
+from ast import mod
+from django.db import models
+from django.utils import timezone
+from numpy import mask_indices, maximum
+
+# Create your models here.
+
+class device(models.Model):
+    device_type = models.CharField(max_length=100, default='ADC')
+    brend = models.CharField(max_length=100, default='Picoscope')
+    serial_model = models.CharField(max_length=100, default='PS400A')
+    proto = models.CharField(max_length=100, default='TCP')
+    proto_interface = models.CharField(max_length=100, default='adc_default')
+    time_creation = models.DateTimeField(default=timezone.now)
+    time_publication = models.DateTimeField(blank=True, null=True)
+    
+    # Потом можно будет добавить ещё что-нибудь
+
+    def set_defaults(self):
+        self.time_publication = timezone.now()
+        self.save()
+        
+    def __str__(self):
+        return self.serial_model  
+
+class adc_params(models.Model):
+    device = models.ForeignKey(device, on_delete=models.PROTECT, related_name='adc_params', null=True)
+    srate = models.PositiveIntegerField()
+    points = models.JSONField(blank=True,
+        null=True,
+        default=list,
+        help_text='Массив целых чисел в формате JSON')
+    n_channels = models.PositiveIntegerField()
+    channel_ranges = models.JSONField(blank=True,
+        null=True,
+        default=list,
+        help_text='Массив целых чисел в формате JSON')
+    n_triggers = models.PositiveIntegerField()
+    trigger_channel = models.PositiveIntegerField()
+    threshold = models.IntegerField()
+    trig_direction = models.IntegerField()
+    auto_measure_time = models.PositiveIntegerField()
+    averaging = models.PositiveIntegerField(default=1)
+    enabled = models.BooleanField(default=True)
+
+    def __str__(self):
+        return self.device.serial_model
+
+class sync_params(models.Model):
+    device = models.ForeignKey(device, on_delete=models.PROTECT, related_name='sync_params', null=True)
+    file = models.CharField(max_length=100, default='Sync_params.xml')
+    port = models.IntegerField(default=7)
+
+    def __str__(self):
+        return self.device.serial_model
+
+
+class sdr_params(models.Model):
+    device = models.ForeignKey(device, on_delete=models.PROTECT, related_name='sdr_params', null=True)
+    srate = models.PositiveIntegerField(default=2000000)
+    freq = models.PositiveIntegerField(default=3000000)
+    gain = models.PositiveIntegerField(default=35)
+    ampl = models.BooleanField(default=True)
+    file = models.CharField(editable=True)
+
+    def __str__(self):
+        return self.device.serial_model
+
+class gra_params(models.Model):
+    device = models.ForeignKey(device, on_delete=models.PROTECT, related_name='gra_params', null=True)
+    ip = models.GenericIPAddressField(default='127.0.0.1')
+    file = models.CharField(editable=True)
+    enabled = models.BooleanField(default=True)
+
+    def __str__(self):
+        return self.device.serial_model
+    
+class measurement_info(models.Model):
+    infostr = models.CharField(max_length=100, default='str')
+    time = models.DateTimeField(default=timezone.now)
+    iadc = models.ForeignKey(adc_params, on_delete=models.CASCADE, related_name='measurement_info6')
+    isync = models.ForeignKey(sync_params, on_delete=models.CASCADE, related_name='measurement_info5')
+    isdr = models.ForeignKey(sdr_params, on_delete=models.CASCADE, related_name='measurement_info4')
+    igrax = models.ForeignKey(gra_params, on_delete=models.CASCADE, related_name='measurement_info3')
+    igray = models.ForeignKey(gra_params, on_delete=models.CASCADE, related_name='measurement_info2')
+    igraz = models.ForeignKey(gra_params, on_delete=models.CASCADE, related_name='measurement_info1')
+    engine = models.CharField(max_length=100, default='DefaultEngine')
+
+    def __str__(self):
+        return self.infostr
+
+class device_state(models.Model):
+    status = models.CharField(max_length=100, default='')
+    code = models.BigIntegerField(default=0)
+
+    def __str__(self):
+        return self.status
+
+class state(models.Model):
+    id_measurement = models.IntegerField(default=0)
+    status = models.CharField(max_length=100, default='default')
+    code = models.BigIntegerField(default=0)
+    adc = models.ForeignKey(device_state, on_delete=models.CASCADE, related_name='state6')
+    sync = models.ForeignKey(device_state, on_delete=models.CASCADE, related_name='state5')
+    sdr = models.ForeignKey(device_state, on_delete=models.CASCADE, related_name='state4')
+    grax = models.ForeignKey(device_state, on_delete=models.CASCADE, related_name='state3')
+    gray = models.ForeignKey(device_state, on_delete=models.CASCADE, related_name='state2')
+    graz = models.ForeignKey(device_state, on_delete=models.CASCADE, related_name='state1')
+    data_ready = models.BooleanField(default=False)
+
+    def __str__(self):
+        return self.status
+
+class measurement(models.Model):
+    info = models.ForeignKey(measurement_info, on_delete=models.CASCADE, related_name='measurement2')
+    state = models.ForeignKey(state, on_delete=models.CASCADE, related_name='measurement1', null=True)
+
+    def __str__(self):
+        return self.info.infostr
+    
+class measurement_data(models.Model):
+    measurement = models.ForeignKey(measurement, on_delete=models.CASCADE, related_name='measurement_data1', null=True)
+    channel_data = models.ManyToManyField('channel_data', related_name='measurement_data2')
+    data_num = models.IntegerField(default=0)
+    averaging_num = models.IntegerField(default=0)
+
+    def __str__(self):
+        return str(self.id)
+    
+class channel_data(models.Model):
+    channel_num = models.IntegerField(default=0)
+    channel_data = models.TextField(editable=True, default='')
+    measurement_data = models.ForeignKey(measurement_data, on_delete=models.CASCADE, related_name='channel_data1', null=True)
+
+    def __str__(self):
+        return str(self.id)

+ 167 - 0
services/spectrometer/spectrometer/serializers.py

@@ -0,0 +1,167 @@
+from django.contrib.auth.models import Group, User
+from . import models
+from rest_framework import serializers
+from drf_writable_nested import WritableNestedModelSerializer
+
+class UserSerializer(serializers.HyperlinkedModelSerializer):
+    class Meta:
+        model = User
+        fields = ['url', 'username', 'email', 'groups']
+
+class device_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    class Meta:
+        model = models.device
+        fields = ['id', 'device_type', 'brend', 'serial_model', 'proto', 'proto_interface']
+
+class adc_params_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    device_model = serializers.CharField(max_length=100, write_only=True)
+    device = device_Serializer(read_only=True)
+    class Meta:
+        model = models.adc_params
+        fields = ['device_model', 'device', 'srate', 'points', 'n_channels', 'channel_ranges', 'n_triggers', 'averaging', 'trigger_channel', 'trig_direction', 'threshold', 'auto_measure_time', 'enabled']
+        extra_kwargs = {
+            'device': {'read_only': True}
+        }
+
+    # task: add create() for get device by serial_model
+    def create(self, validated_data):
+        # get device by serial_model
+        device_model = validated_data.pop('device_model')
+        device_instance = models.device.objects.get(serial_model=device_model)
+        # create adc_params
+        adc_params_instance = models.adc_params.objects.create(device=device_instance, **validated_data)
+        return adc_params_instance
+
+class sync_params_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    device_model = serializers.CharField(max_length=100, write_only=True)
+    device = device_Serializer(read_only=True)
+    class Meta:
+        model = models.sync_params
+        fields = ['device_model', 'device', 'file', 'port']
+        extra_kwargs = {
+            'device': {'read_only': True}
+        }
+
+    def create(self, validated_data):
+        # get device by serial_model
+        device_model = validated_data.pop('device_model')
+        device_instance = models.device.objects.get(serial_model=device_model)
+        # create sync_params with the device and other fields
+        sync_params_instance = models.sync_params.objects.create(device=device_instance, **validated_data)
+        return sync_params_instance
+
+class sdr_params_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    device_model = serializers.CharField(max_length=100, write_only=True)
+    device = device_Serializer(read_only=True)
+    class Meta:
+        model = models.sdr_params
+        fields = ['device_model', 'device', 'srate', 'freq', 'ampl', 'gain', 'file']
+        extra_kwargs = {
+            'device': {'read_only': True}
+        }
+
+    def create(self, validated_data):
+        # get device by serial_model
+        device_model = validated_data.pop('device_model')
+        device_instance = models.device.objects.get(serial_model=device_model)
+        # create sdr_params
+        sdr_params_instance = models.sdr_params.objects.create(device=device_instance, **validated_data)
+        return sdr_params_instance
+
+class gra_params_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    device_model = serializers.CharField(max_length=100, write_only=True)
+    device = device_Serializer(read_only=True)
+    class Meta:
+        model = models.gra_params
+        fields = ['device_model', 'device', 'ip', 'file', 'enabled']
+        extra_kwargs = {
+            'device': {'read_only': True}
+        }
+
+    def create(self, validated_data):
+        # get device by serial_model
+        device_model = validated_data.pop('device_model')
+        device_instance = models.device.objects.get(serial_model=device_model)
+        # create gra_params
+        gra_params_instance = models.gra_params.objects.create(device=device_instance, **validated_data)
+        return gra_params_instance
+
+class measurement_info_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    #id = serializers.IntegerField()
+    iadc = adc_params_Serializer()
+    isync = sync_params_Serializer()
+    isdr = sdr_params_Serializer()
+    igrax = gra_params_Serializer()
+    igray = gra_params_Serializer()
+    igraz = gra_params_Serializer()
+    class Meta:
+        model = models.measurement_info
+        fields = ['infostr', 'engine', 'time', 'iadc', 'isync', 'isdr', 'igrax', 'igray', 'igraz']
+
+class measurement_info_lite_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    class Meta:
+        model = models.measurement_info
+        fields = ['id', 'infostr', 'time']
+
+class channel_data_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    channel_data = serializers.CharField(allow_blank=True, 
+                                trim_whitespace=False,
+                                required=False)
+    #id = serializers.IntegerField()
+    class Meta:
+        model = models.channel_data
+        fields = ['channel_num', 'channel_data']
+
+class device_state_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    class Meta:
+        model = models.device_state
+        fields = ['status', 'code']
+
+class state_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    #id = serializers.IntegerField()
+    adc = device_state_Serializer()
+    sdr = device_state_Serializer()
+    sync = device_state_Serializer()
+    grax = device_state_Serializer()
+    gray = device_state_Serializer()
+    graz = device_state_Serializer()
+    data_ready = serializers.BooleanField(default=False)
+    class Meta:
+        model = models.state
+        fields = ['adc', 'sync', 'sdr', 'grax', 'gray', 'graz', 'code', 'status', 'data_ready']
+
+class measurement_data_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    #id = serializers.IntegerField()
+    channel_data = channel_data_Serializer(read_only=True, many=True)
+    measurement_id = serializers.IntegerField(read_only=True, source="measurement.id")
+    measurement_rate = serializers.IntegerField(read_only=True, source="measurement.info.iadc.srate")
+    measurement_points = serializers.JSONField(read_only=True, source="measurement.info.iadc.points")
+    #channel_data.id = id_data
+    class Meta:
+        model = models.measurement_data
+        fields = ['measurement_id', 'data_num', 'averaging_num', 'channel_data', 'measurement_rate', 'measurement_points']
+
+class measurement_post_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    #id = serializers.IntegerField()
+    info = measurement_info_Serializer(write_only=True)
+    infostr = serializers.CharField(read_only=True, source="info.infostr")
+    #info.id = id
+    #state.id = id
+    class Meta:
+        model = models.measurement
+        fields = ['id', 'info', 'infostr']
+        extra_kwargs = {
+            'info': {'write_only': True}
+        }
+
+class measurement_Serializer(WritableNestedModelSerializer, serializers.HyperlinkedModelSerializer):
+    #id = serializers.IntegerField()
+    #info = measurement_info_Serializer()
+    #info.id = id
+    #data.id = id
+    #state.id = id
+    infostr = serializers.CharField(read_only=True, source="info.infostr")
+
+    class Meta:
+        model = models.measurement
+        fields = ['id', 'infostr']

+ 11 - 0
services/spectrometer/spectrometer/templates/spectrometer/index.html

@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>ADC Interface</title>
+</head>
+<body>
+    <h1 class="title has-text-centered">ADC Interface</h1>
+</body>
+</html>

+ 3 - 0
services/spectrometer/spectrometer/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 6 - 0
services/spectrometer/spectrometer/urls.py

@@ -0,0 +1,6 @@
+from django.urls import path
+from . import views
+
+urlpatterns = [
+    path('', views.post_list, name='post_list'),
+]

+ 196 - 0
services/spectrometer/spectrometer/views.py

@@ -0,0 +1,196 @@
+from warnings import filters
+from django.shortcuts import render
+from django.contrib.auth.models import Group, User
+from rest_framework import permissions, viewsets
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.parsers import JSONParser,ParseError
+from rest_framework import status
+from rest_framework import generics
+from . import engine
+import threading
+import django_filters.rest_framework
+from rest_framework.decorators import action
+from django.conf import settings
+from django.shortcuts import get_object_or_404
+
+
+from . import models
+from . import serializers
+
+eng = 0
+lock = threading.Lock()
+
+class UserViewSet(viewsets.ModelViewSet):
+    """
+    API endpoint that allows users to be viewed or edited.
+    """
+    queryset = User.objects.all().order_by('-date_joined')
+    serializer_class = serializers.UserSerializer
+    permission_classes = [permissions.IsAuthenticated]
+
+class device_ViewSet(viewsets.ModelViewSet):
+    """
+    API endpoint that allows users to be viewed or edited.
+    """
+    queryset = models.device.objects.all().order_by('-time_creation',)
+    serializer_class = serializers.device_Serializer
+    permission_classes = [permissions.IsAuthenticated]
+
+    filtred_backends = [django_filters.rest_framework.DjangoFilterBackend]
+    filterset_fields = ['device_type', 'brend', 'serial_model']
+
+
+
+class measurement_info_Viewset(viewsets.ModelViewSet):
+    """
+    API endpoint that allows users to be viewed or edited.
+    """
+    queryset = models.measurement_info.objects.all().order_by('-id',)
+    serializer_class = serializers.measurement_info_Serializer
+    permission_classes = [permissions.IsAuthenticated]
+
+    filtred_backends = [django_filters.rest_framework.DjangoFilterBackend]
+    filterset_fields = ['id', 'infostr']
+
+class state_Viewset(viewsets.ModelViewSet):
+    """
+    API endpoint that allows users to be viewed or edited.
+    """
+    queryset = models.state.objects.all().order_by('-id',)
+    serializer_class = serializers.state_Serializer
+    permission_classes = [permissions.IsAuthenticated]
+
+    filtred_backends = [django_filters.rest_framework.DjangoFilterBackend]
+    filterset_fields = ['id_measurement']
+        
+class measurement_ViewSet(viewsets.ModelViewSet):
+    """
+    API endpoint that allows users to be viewed or edited.
+    """
+    queryset = models.measurement.objects.all().order_by('-id',)
+    serializer_class = serializers.measurement_post_Serializer
+    permission_classes = [permissions.IsAuthenticated]
+
+    #filtred_backends = [django_filters.rest_framework.DjangoFilterBackend]
+    #filterset_fields = ['id']
+
+    @action(methods=['get'], detail=True, permission_classes=[permissions.IsAuthenticated],
+            url_path='data', url_name='data')
+    def data(self, request, pk=None):
+        try:
+            measurement = models.measurement.objects.get(id=pk)
+        except models.measurement.DoesNotExist:
+            return Response(
+                {
+                    "status": f"Measurement {pk} does not exist!"
+                },
+                status=status.HTTP_404_NOT_FOUND,
+            )
+        if(measurement.state.data_ready != True):
+            return Response(
+                {
+                    "status": "Data isn't ready!"
+                },
+                status=status.HTTP_403_FORBIDDEN,
+            )
+        data_num = request.GET.get('data_num', None)
+        averaging_num = request.GET.get('averaging_num', None)
+
+        if(data_num == None):
+            if(averaging_num == None):
+                queryset = models.measurement_data.objects.filter(measurement_id=pk).order_by('-id',)
+            else:
+                queryset = models.measurement_data.objects.filter(measurement_id=pk, averaging_num=averaging_num).order_by('-id',)
+        else:
+            if(averaging_num == None):
+                queryset = models.measurement_data.objects.filter(measurement_id=pk, data_num=data_num).order_by('-id',)
+            else:
+                queryset = models.measurement_data.objects.filter(measurement_id=pk, data_num=data_num, averaging_num=averaging_num).order_by('-id',)
+        serializer = serializers.measurement_data_Serializer(queryset, many=True)
+        return Response(serializer.data)
+
+    @action(methods=['get'], detail=True, permission_classes=[permissions.IsAuthenticated],
+            url_path='state', url_name='state')
+    def state(self, request, pk=None):
+        queryset = models.measurement.objects.all().order_by('-id',)
+        measurement = get_object_or_404(queryset, id=pk)
+        serializer = serializers.state_Serializer(measurement.state)
+        return Response(serializer.data)
+
+    @action(methods=['get'], detail=True, permission_classes=[permissions.IsAuthenticated],
+            url_path='info', url_name='info')
+    def info(self, request, pk=None):
+        queryset = models.measurement.objects.all().order_by('-id',)
+        measurement = get_object_or_404(queryset, id=pk)
+        serializer = serializers.measurement_info_Serializer(measurement.info)
+        return Response(serializer.data)
+    
+    def create(self, request, *args, **kwargs):
+        serializer = serializers.measurement_post_Serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        
+        #state_ser = serializers.state_Serializer(state)
+
+        # Сохранение объекта (ID инкрементируется автоматически)
+
+        adc = models.device_state.objects.create()
+        sync = models.device_state.objects.create()
+        sdr = models.device_state.objects.create()
+        grax = models.device_state.objects.create()
+        gray = models.device_state.objects.create()
+        graz = models.device_state.objects.create()
+
+        self.perform_create(serializer)
+        mid = serializer.data['id']
+        instance = models.measurement.objects.get(id=mid)
+        instance.state = models.state.objects.create(id_measurement=mid, adc=adc, sync=sync, sdr=sdr, grax=grax, gray=gray, graz=graz)
+        print(settings.BASE_DIR)
+        instance.save()
+
+        eng = engine.EngineDict[instance.info.engine](instance, lock)
+        
+        # Форматирование ответа
+        headers = self.get_success_headers(serializer.data)
+        return Response(
+            {
+                "status": "created",
+                "measurement_id": mid,
+                "basedir": str(settings.BASE_DIR)
+            },
+            status=status.HTTP_201_CREATED,
+            headers=headers
+        )
+    
+    def destroy(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        instance.state.delete()
+        instance.info.delete()
+        instance.info.iadc.delete()
+        instance.info.isync.delete()
+        instance.info.isdr.delete()
+        instance.info.igrax.delete()
+        instance.info.igray.delete()
+        instance.info.igraz.delete()
+        instance.state.adc.delete()
+        instance.state.sync.delete()
+        instance.state.sdr.delete()
+        instance.state.grax.delete()
+        instance.state.gray.delete()
+        instance.state.graz.delete()
+        
+        self.perform_destroy(instance)
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+class measurement_data_ViewSet(viewsets.ModelViewSet):
+    queryset = models.measurement_data.objects.all().order_by('-id',)
+    serializer_class = serializers.measurement_data_Serializer
+    permission_classes = [permissions.IsAuthenticated]
+
+    filtred_backends = [django_filters.rest_framework.DjangoFilterBackend]
+    filterset_fields = ['measurement__id', 'data_num', 'averaging_num']
+
+# Create your views here.
+def post_list(request):
+    return render(request, 'adc_interface/index.html', {})

+ 12 - 0
services/spectrometer/temp

@@ -0,0 +1,12 @@
+---- due_upload_trajectory ------
+duepp: Sending prog size: 16 D serial.available = 12
+D serial.readString = 16 size ok

+
+duepp: Sending program: 
+program->dpos: 16
+---- send ------
+D serial.available = 0
+D serial.readString = 
+D serial.available = 22
+D serial.readString = 254 86 data received

+

+ 1 - 0
services/spectroscopy

@@ -0,0 +1 @@
+Subproject commit b3e19b0fcc4c87fe2668cba36ae86d34e433ab68

+ 18 - 29
start.ps1

@@ -1,34 +1,32 @@
 # ==============================================================================
 #  lf_mri_platform — Start services and GUI
 #  Usage:
-#    .\start.ps1              — start Docker services + GUI
-#    .\start.ps1 -GuiOnly     — start GUI only (no Docker)
-#    .\start.ps1 -Mode real   — start in real hardware mode
-#    .\start.ps1 -ServicesOnly — start Docker services only
+#    .\start.ps1                — start Docker services + GUI
+#    .\start.ps1 -GuiOnly       — start GUI only (no Docker)
+#    .\start.ps1 -Mode real     — start in real hardware mode
+#    .\start.ps1 -ServicesOnly  — start Docker services only
 # ==============================================================================
 param(
     [switch]$GuiOnly,
     [switch]$ServicesOnly,
-    [ValidateSet("plug","real")]
-    [string]$Mode = "plug",
-    [string]$GuiDir  = "..\lf_mri\MRI-testing\lf_mri_gui",
-    [string]$RepoRoot = "..\lf_mri\MRI-testing"
+    [ValidateSet("plug", "real")]
+    [string]$Mode = "plug"
 )
 
 $ErrorActionPreference = "Stop"
+$Root      = $PSScriptRoot
+$GuiDir    = Join-Path $Root "apps\gui"
+$venvPython = Join-Path $GuiDir ".venv\Scripts\python.exe"
+$appScript  = Join-Path $GuiDir "app.py"
 
 function Write-Step($msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan }
 function Write-OK($msg)   { Write-Host "    [OK] $msg" -ForegroundColor Green }
 function Write-Warn($msg) { Write-Host "    [!!] $msg" -ForegroundColor Yellow }
 
-$venvPython = Join-Path $GuiDir ".venv\Scripts\python.exe"
-$appScript  = Join-Path $GuiDir "app.py"
-
 # ── Start Docker services ─────────────────────────────────────────────────────
 if (-not $GuiOnly) {
     Write-Step "Starting Docker services (mode: $Mode)"
 
-    # Check Docker is running
     try { & docker info *>$null }
     catch {
         Write-Warn "Docker is not running. Starting Docker Desktop..."
@@ -37,34 +35,27 @@ if (-not $GuiOnly) {
         Start-Sleep 20
     }
 
-    # Load .env
-    $envFile = Join-Path $PSScriptRoot ".env"
+    $envFile = Join-Path $Root ".env"
     if (-not (Test-Path $envFile)) {
-        Copy-Item (Join-Path $PSScriptRoot ".env.example") $envFile
+        Copy-Item (Join-Path $Root ".env.example") $envFile
         Write-Warn ".env not found — created from .env.example"
     }
 
-    # Set mode and launch
     $env:ORCHESTRATOR_MODE = $Mode
     & docker compose --env-file $envFile up --build -d
 
     Write-OK "Services started in '$Mode' mode"
-    Write-Host ""
     Write-Host "    Waiting for services to become healthy..." -ForegroundColor DarkGray
 
-    # Wait for orchestrator health (up to 60s)
-    $maxWait = 60
-    $elapsed = 0
-    $orchPort = (Get-Content $envFile | Select-String "ORCHESTRATOR_PORT=(\d+)").Matches[0].Groups[1].Value
+    $maxWait   = 60
+    $elapsed   = 0
+    $orchPort  = (Get-Content $envFile | Select-String "ORCHESTRATOR_PORT=(\d+)").Matches[0].Groups[1].Value
     if (-not $orchPort) { $orchPort = "1717" }
 
     while ($elapsed -lt $maxWait) {
         try {
             $r = Invoke-WebRequest "http://localhost:$orchPort/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction SilentlyContinue
-            if ($r.StatusCode -eq 200) {
-                Write-OK "Orchestrator is up (http://localhost:$orchPort)"
-                break
-            }
+            if ($r.StatusCode -eq 200) { Write-OK "Orchestrator is up (http://localhost:$orchPort)"; break }
         } catch {}
         Start-Sleep 3
         $elapsed += 3
@@ -80,15 +71,13 @@ if (-not $ServicesOnly) {
     Write-Step "Starting LF-MRI GUI"
 
     if (-not (Test-Path $venvPython)) {
-        Write-Warn "Venv not found at $venvPython"
-        Write-Warn "Run .\install.ps1 first, or using system python..."
+        Write-Warn "Venv not found. Run .\install.ps1 first. Falling back to system Python..."
         $venvPython = "python"
     }
 
     Write-OK "Launching GUI..."
-    $absRepoRoot = Resolve-Path $RepoRoot
     Start-Process $venvPython -ArgumentList "`"$appScript`"" `
-        -WorkingDirectory $absRepoRoot `
+        -WorkingDirectory $Root `
         -WindowStyle Normal
 }
 

Some files were not shown because too many files changed in this diff