steam_tab.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. from __future__ import annotations
  2. import json
  3. import math
  4. import os
  5. from datetime import datetime
  6. from types import SimpleNamespace
  7. from typing import Dict, Tuple
  8. from PyQt6.QtCore import QTimer
  9. from PyQt6.QtWidgets import QDoubleSpinBox, QFileDialog, QMessageBox, QSpinBox
  10. try:
  11. from test_seqgen.seqgen_STEAM import seqgen_STEAM
  12. _SEQGEN_IMPORT_ERROR = ""
  13. except Exception as _e:
  14. seqgen_STEAM = None
  15. _SEQGEN_IMPORT_ERROR = str(_e)
  16. class SteamTabMixin:
  17. def _steam_gradient_limits(
  18. self,
  19. g_amp_max_mtm: float,
  20. g_slew_max_tms: float,
  21. grad_raster_time_us: float,
  22. ) -> Tuple[float, float, float, float, float]:
  23. gamma = 42.576e6
  24. grad_raster_time = max(grad_raster_time_us * 1e-6, 1e-12)
  25. g_amp_max = g_amp_max_mtm * 1e-3 * gamma
  26. g_slew_max = max(g_slew_max_tms * gamma, 1e-12)
  27. tau_max = math.ceil((g_amp_max / g_slew_max) / grad_raster_time) * grad_raster_time
  28. return gamma, g_amp_max, g_slew_max, grad_raster_time, tau_max
  29. def _init_steam_tab(self) -> None:
  30. if self.steam is None:
  31. return
  32. self._cache_steam_default_ranges()
  33. self._cache_steam_label_bases()
  34. self._wire_steam_param_inputs()
  35. if not self.steam.leSteamOutput.text().strip():
  36. self.steam.leSteamOutput.setText(f"STEAM_{datetime.now().strftime('%d%m%y_%H%M')}")
  37. if seqgen_STEAM is None:
  38. self.steam.lblSteamStatus.setText("seqgen_STEAM import failed. Check dependencies.")
  39. return
  40. QTimer.singleShot(0, self.steam_set_limits)
  41. def _cache_steam_default_ranges(self) -> None:
  42. if self.steam is None:
  43. return
  44. widgets = (
  45. self.steam.sbSteamTExMs,
  46. self.steam.sbSteamTau1Ms,
  47. self.steam.sbSteamTau2Ms,
  48. self.steam.sbSteamTR,
  49. )
  50. for w in widgets:
  51. self._steam_default_ranges[w.objectName()] = (float(w.minimum()), float(w.maximum()))
  52. def _cache_steam_label_bases(self) -> None:
  53. if self.steam is None:
  54. return
  55. labels = (self.steam.lblTau1, self.steam.lblTau2, self.steam.lblTR, self.steam.lblTEx)
  56. for lbl in labels:
  57. self._steam_label_bases[lbl.objectName()] = lbl.text()
  58. def _wire_steam_param_inputs(self) -> None:
  59. if self.steam is None:
  60. return
  61. for sb in self.steam.gbSeqParams.findChildren(QDoubleSpinBox):
  62. sb.valueChanged.connect(self._on_steam_param_changed)
  63. for sb in self.steam.gbSeqParams.findChildren(QSpinBox):
  64. sb.valueChanged.connect(self._on_steam_param_changed)
  65. def _on_steam_param_changed(self, *_args) -> None:
  66. if self.steam is None:
  67. return
  68. if self._steam_internal_update:
  69. return
  70. self._steam_params_dirty = True
  71. self.steam_set_limits(update_status=False, write_log=False)
  72. self._update_steam_seq_params_highlight()
  73. self.steam.lblSteamStatus.setText("Parameters changed. SeqGen not run yet.")
  74. def _update_steam_seq_params_highlight(self) -> None:
  75. if self.steam is None:
  76. return
  77. if self._steam_params_dirty:
  78. self.steam.gbSeqParams.setStyleSheet(
  79. "QGroupBox#gbSeqParams { background-color: #ABA8A7;} "
  80. "QGroupBox#gbSeqParams QLabel { color: #000000; } "
  81. )
  82. else:
  83. self.steam.gbSeqParams.setStyleSheet("")
  84. def _recalculate_steam_param_ranges(self) -> None:
  85. if self.steam is None:
  86. return
  87. g_amp_max_mtm = float(self.steam.sbSteamGAmpMax.value())
  88. g_slew_max_tms = float(self.steam.sbSteamGSlewMax.value())
  89. grad_raster_time_us = float(self.steam.sbSteamGradRasterUs.value())
  90. bw_per_point = float(self.steam.sbSteamBWPerPoint.value())
  91. tau1_ms = float(self.steam.sbSteamTau1Ms.value())
  92. tau2_ms = float(self.steam.sbSteamTau2Ms.value())
  93. tr_s = float(self.steam.sbSteamTR.value())
  94. t_ex_ms = float(self.steam.sbSteamTExMs.value())
  95. _, _, _, _, tau_max = self._steam_gradient_limits(
  96. g_amp_max_mtm=g_amp_max_mtm,
  97. g_slew_max_tms=g_slew_max_tms,
  98. grad_raster_time_us=grad_raster_time_us,
  99. )
  100. tau1_s = tau1_ms * 1e-3
  101. tau2_s = tau2_ms * 1e-3
  102. t_ex_s = t_ex_ms * 1e-3
  103. grad_block_s = t_ex_s + 2.0 * tau_max
  104. adc_duration_s = 0.5 / max(bw_per_point, 1e-12)
  105. # Timing constraints from seqgen_STEAM:
  106. # tau1 >= grad_block, tau2 >= grad_block, TR >= 2*tau1 + tau2 + adc + 0.5*grad_block
  107. tau1_min_ms = grad_block_s * 1e3
  108. tau2_min_ms = grad_block_s * 1e3
  109. tr_min_s = 2.0 * tau1_s + tau2_s + adc_duration_s + 0.5 * grad_block_s
  110. # Upper bounds driven by current TR and paired delays.
  111. tau1_max_from_tr_ms = ((tr_s - tau2_s - adc_duration_s - 0.5 * grad_block_s) / 2.0) * 1e3
  112. tau2_max_from_tr_ms = (tr_s - 2.0 * tau1_s - adc_duration_s - 0.5 * grad_block_s) * 1e3
  113. tau1_def_min, tau1_def_max = self._steam_default_ranges.get(
  114. self.steam.sbSteamTau1Ms.objectName(),
  115. (float(self.steam.sbSteamTau1Ms.minimum()), float(self.steam.sbSteamTau1Ms.maximum())),
  116. )
  117. tau2_def_min, tau2_def_max = self._steam_default_ranges.get(
  118. self.steam.sbSteamTau2Ms.objectName(),
  119. (float(self.steam.sbSteamTau2Ms.minimum()), float(self.steam.sbSteamTau2Ms.maximum())),
  120. )
  121. tr_def_min, tr_def_max = self._steam_default_ranges.get(
  122. self.steam.sbSteamTR.objectName(),
  123. (float(self.steam.sbSteamTR.minimum()), float(self.steam.sbSteamTR.maximum())),
  124. )
  125. tex_def_min, tex_def_max = self._steam_default_ranges.get(
  126. self.steam.sbSteamTExMs.objectName(),
  127. (float(self.steam.sbSteamTExMs.minimum()), float(self.steam.sbSteamTExMs.maximum())),
  128. )
  129. tau1_min = max(tau1_def_min, tau1_min_ms)
  130. tau2_min = max(tau2_def_min, tau2_min_ms)
  131. tr_min = max(tr_def_min, tr_min_s)
  132. tau1_max = min(tau1_def_max, max(tau1_min, tau1_max_from_tr_ms))
  133. tau2_max = min(tau2_def_max, max(tau2_min, tau2_max_from_tr_ms))
  134. tex_max_from_taus_ms = min(tau1_s, tau2_s) * 1e3 - (2.0 * tau_max * 1e3)
  135. tex_min = tex_def_min
  136. tex_max = min(tex_def_max, max(tex_min, tex_max_from_taus_ms))
  137. self._steam_internal_update = True
  138. try:
  139. self.steam.sbSteamTau1Ms.setMinimum(tau1_min)
  140. self.steam.sbSteamTau1Ms.setMaximum(tau1_max)
  141. self.steam.sbSteamTau2Ms.setMinimum(tau2_min)
  142. self.steam.sbSteamTau2Ms.setMaximum(tau2_max)
  143. self.steam.sbSteamTR.setMinimum(tr_min)
  144. self.steam.sbSteamTR.setMaximum(tr_def_max)
  145. self.steam.sbSteamTExMs.setMinimum(tex_min)
  146. self.steam.sbSteamTExMs.setMaximum(tex_max)
  147. self.steam.sbSteamTau1Ms.setToolTip(f"Range: {tau1_min:.6f} .. {tau1_max:.6f} ms")
  148. self.steam.sbSteamTau2Ms.setToolTip(f"Range: {tau2_min:.6f} .. {tau2_max:.6f} ms")
  149. self.steam.sbSteamTR.setToolTip(f"Range: {tr_min:.6f} .. {tr_def_max:.6f} s")
  150. self.steam.sbSteamTExMs.setToolTip(f"Range: {tex_min:.6f} .. {tex_max:.6f} ms")
  151. self.steam.lblTau1.setText(
  152. f"{self._steam_label_bases.get('lblTau1', 'tau1, ms')} [{tau1_min:.3f} .. {tau1_max:.3f}]"
  153. )
  154. self.steam.lblTau2.setText(
  155. f"{self._steam_label_bases.get('lblTau2', 'tau2, ms')} [{tau2_min:.3f} .. {tau2_max:.3f}]"
  156. )
  157. self.steam.lblTR.setText(
  158. f"{self._steam_label_bases.get('lblTR', 'TR, s')} [{tr_min:.3f} .. {tr_def_max:.3f}]"
  159. )
  160. self.steam.lblTEx.setText(
  161. f"{self._steam_label_bases.get('lblTEx', 't_ex, ms')} [{tex_min:.3f} .. {tex_max:.3f}]"
  162. )
  163. finally:
  164. self._steam_internal_update = False
  165. def steam_set_limits(self, update_status: bool = True, write_log: bool = True) -> None:
  166. if self.steam is None:
  167. return
  168. self._recalculate_steam_param_ranges()
  169. average = float(self.steam.sbAverage.value())
  170. g_amp_max_mtm = float(self.steam.sbSteamGAmpMax.value())
  171. g_slew_max_tms = float(self.steam.sbSteamGSlewMax.value())
  172. grad_raster_time_us = float(self.steam.sbSteamGradRasterUs.value())
  173. rf_raster_time_us = float(self.steam.sbSteamRfRasterUs.value())
  174. rf_ringdown_us = float(self.steam.sbSteamRfRingdownUs.value())
  175. rf_dead_us = float(self.steam.sbSteamRfDeadUs.value())
  176. adc_dead_us = float(self.steam.sbSteamAdcDeadUs.value())
  177. voxel_thkn_mm = float(self.steam.sbSteamVoxelThknMm.value())
  178. bw_per_point = float(self.steam.sbSteamBWPerPoint.value())
  179. n_point = int(self.steam.sbSteamNPoint.value())
  180. tau1_ms = float(self.steam.sbSteamTau1Ms.value())
  181. tau2_ms = float(self.steam.sbSteamTau2Ms.value())
  182. tr = float(self.steam.sbSteamTR.value())
  183. t_ex_ms = float(self.steam.sbSteamTExMs.value())
  184. t_bw_product_ex = float(self.steam.sbSteamTBW.value())
  185. flip_angle = float(self.steam.sbSteamFlipAngle.value())
  186. apodization = float(self.steam.sbSteamApodization.value())
  187. gamma, g_amp_max, g_slew_max, grad_raster_time, tau_max = self._steam_gradient_limits(
  188. g_amp_max_mtm=g_amp_max_mtm,
  189. g_slew_max_tms=g_slew_max_tms,
  190. grad_raster_time_us=grad_raster_time_us,
  191. )
  192. rf_raster_time = rf_raster_time_us * 1e-6
  193. rf_ringdown_time = rf_ringdown_us * 1e-6
  194. rf_dead_time = rf_dead_us * 1e-6
  195. adc_dead_time = adc_dead_us * 1e-6
  196. voxel_thkn = voxel_thkn_mm * 1e-3
  197. tau1 = tau1_ms * 1e-3
  198. tau2 = tau2_ms * 1e-3
  199. t_ex = t_ex_ms * 1e-3
  200. bw_ex_pulse = t_bw_product_ex / t_ex
  201. self._steam_params = {
  202. "G_amp_max": g_amp_max,
  203. "G_slew_max": g_slew_max,
  204. "gamma": gamma,
  205. "grad_raster_time": grad_raster_time,
  206. "rf_raster_time": rf_raster_time,
  207. "t_ex": t_ex,
  208. "t_BW_product_ex": t_bw_product_ex,
  209. "FA": flip_angle,
  210. "apodization": apodization,
  211. "BW_ex_pulse": bw_ex_pulse,
  212. "rf_ringdown_time": rf_ringdown_time,
  213. "rf_dead_time": rf_dead_time,
  214. "adc_dead_time": adc_dead_time,
  215. "voxel_thkn": voxel_thkn,
  216. "N_point": n_point,
  217. "BW_per_point": bw_per_point,
  218. "dG": tau_max,
  219. "tau1": tau1,
  220. "tau2": tau2,
  221. "TR": tr,
  222. "average": average,
  223. }
  224. if update_status:
  225. self.steam.lblSteamStatus.setText("STEAM parameters updated.")
  226. if write_log:
  227. self.log("[STEAM] Parameters updated.")
  228. def steam_save_sequence(self) -> None:
  229. self._steam_save_sequence(save_as=False)
  230. def steam_save_sequence_as(self) -> None:
  231. self._steam_save_sequence(save_as=True)
  232. def _steam_default_sequences_dir(self) -> str:
  233. project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
  234. return os.path.join(project_root, "data", "sequences")
  235. def _steam_save_sequence(self, save_as: bool) -> None:
  236. if self.steam is None:
  237. return
  238. if seqgen_STEAM is None:
  239. msg = "Cannot import test_seqgen.seqgen_STEAM."
  240. if _SEQGEN_IMPORT_ERROR:
  241. msg = f"{msg}\n\nDetails: {_SEQGEN_IMPORT_ERROR}"
  242. QMessageBox.warning(self, "STEAM unavailable", msg)
  243. return
  244. self.steam_set_limits()
  245. default_base = self.steam.leSteamOutput.text().strip() or f"STEAM_{datetime.now().strftime('%d%m%y_%H%M')}"
  246. default_seq = f"{default_base}.seq"
  247. seq_dir = self._steam_default_sequences_dir()
  248. os.makedirs(seq_dir, exist_ok=True)
  249. seq_path = os.path.join(seq_dir, default_seq)
  250. if save_as:
  251. seq_path, _ = QFileDialog.getSaveFileName(
  252. self,
  253. "Save STEAM sequence as",
  254. seq_path,
  255. "Sequence (*.seq);;All files (*)",
  256. )
  257. if not seq_path:
  258. return
  259. if not seq_path.lower().endswith(".seq"):
  260. seq_path += ".seq"
  261. json_path = os.path.splitext(seq_path)[0] + ".json"
  262. try:
  263. params = SimpleNamespace(**self._steam_params)
  264. seq = seqgen_STEAM(params)
  265. seq.write(seq_path)
  266. with open(json_path, "w", encoding="utf-8") as f:
  267. json.dump(self._steam_params, f, ensure_ascii=False, indent=2)
  268. self._steam_params_dirty = False
  269. self._update_steam_seq_params_highlight()
  270. self._sequence_file_path = seq_path
  271. self._refresh_current_sequence_ui()
  272. self.steam.leSteamOutput.setText(os.path.splitext(os.path.basename(seq_path))[0])
  273. self.steam.lblSteamStatus.setText(f"Saved: {seq_path}")
  274. self.log(f"[STEAM] Saved sequence: {seq_path}")
  275. self.log(f"[STEAM] Saved params: {json_path}")
  276. except Exception as e:
  277. self.steam.lblSteamStatus.setText("Save failed.")
  278. QMessageBox.critical(self, "STEAM save error", str(e))