scanning_tab.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124
  1. """
  2. ScanningTab - clinical MRI scanner UI simulation with real scan initiation.
  3. Panels:
  4. - Left: scrollable protocol queue (ProtocolListWidget)
  5. - Centre: 3 MRI image viewers (MriViewerWidget x 3)
  6. - Bottom: QTabWidget with parameter tabs; "Геометрия" tab holds rotation controls
  7. Rotation matrix (3x3, ZYX Euler) is merged into the seq_info dict and sent
  8. to the orchestrator's start_measurement step as "rotation_matrix".
  9. """
  10. from __future__ import annotations
  11. import math
  12. import json
  13. import os
  14. import numpy as np
  15. from PySide6.QtCore import Qt, QThread, QTimer, Signal
  16. from PySide6.QtGui import (
  17. QColor, QFont, QImage, QLinearGradient, QPainter, QPen, QPolygonF,
  18. )
  19. from PySide6.QtCore import QPointF
  20. from PySide6.QtWidgets import (
  21. QButtonGroup, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
  22. QGridLayout, QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
  23. QPushButton, QSplitter, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
  24. )
  25. # -- colour palette -------------------------------------------------------------
  26. _BG_DARK = "#1a1a2e"
  27. _PANEL_BG = "#2a2a2a"
  28. _IMAGE_BG = "#000000"
  29. _ACCENT = "#f0c040"
  30. _ACCENT_DIM = "#555533"
  31. _ORANGE_SEL = "#e65100"
  32. _BTN_BG = "#252535"
  33. # -- animation -----------------------------------------------------------------
  34. _SCAN_LINE_INTERVAL_MS = 40 # 25 fps
  35. _SCAN_SWEEP_DURATION = 120 # ticks per vertical sweep
  36. # -- mouse interaction ---------------------------------------------------------
  37. _ROT_SENSITIVITY = 0.4 # degrees per pixel for Ctrl+drag rotation
  38. # -- default orientations (Rx, Ry, Rz in degrees) ------------------------------
  39. _PRESETS = {
  40. "Axial": (0.0, 0.0, 0.0),
  41. "Coronal": (90.0, 0.0, 0.0),
  42. "Sagittal": (0.0, 90.0, 0.0),
  43. }
  44. _PROTOCOLS = [
  45. "FID", "SE", "TSE"
  46. ]
  47. # Physical axes for each viewer plane: (horizontal_axis, vertical_axis)
  48. # X=0, Y=1, Z=2 in physical space; Y is flipped to screen coords in paintEvent
  49. _VIEWER_AXES: dict[str, tuple[int, int]] = {
  50. "Axial": (0, 1),
  51. "Cor": (0, 2),
  52. "Sag": (1, 2),
  53. }
  54. # ==============================================================================
  55. def _euler_to_matrix(rx_deg: float, ry_deg: float, rz_deg: float) -> list:
  56. """ZYX Euler angles (degrees) -> 3x3 rotation matrix as list-of-lists."""
  57. rx = math.radians(rx_deg)
  58. ry = math.radians(ry_deg)
  59. rz = math.radians(rz_deg)
  60. cx, sx = math.cos(rx), math.sin(rx)
  61. cy, sy = math.cos(ry), math.sin(ry)
  62. cz, sz = math.cos(rz), math.sin(rz)
  63. Rx = np.array([[1, 0, 0 ],
  64. [0, cx, -sx],
  65. [0, sx, cx]])
  66. Ry = np.array([[ cy, 0, sy],
  67. [ 0, 1, 0],
  68. [-sy, 0, cy]])
  69. Rz = np.array([[cz, -sz, 0],
  70. [sz, cz, 0],
  71. [0, 0, 1]])
  72. R = Rz @ Ry @ Rx
  73. return [[round(float(v), 6) for v in row] for row in R]
  74. def _project_slice_quad(R: list, viewer_label: str, size: float = 0.42) -> list:
  75. """
  76. Project the scan-plane square onto a viewer's 2D projection plane.
  77. The scan square lies in the (freq-encode, phase-encode) plane.
  78. freq direction = R column 0, phase direction = R column 1.
  79. Viewer planes:
  80. "Axial" -> XY (physical axes 0, 1)
  81. "Cor" -> XZ (physical axes 0, 2)
  82. "Sag" -> YZ (physical axes 1, 2)
  83. Returns list of 4 (u, v) tuples in [-1, 1] space, Y-up convention.
  84. """
  85. ax0, ax1 = _VIEWER_AXES.get(viewer_label, (0, 1))
  86. # freq and phase unit vectors in physical space
  87. freq = [R[i][0] for i in range(3)]
  88. phase = [R[i][1] for i in range(3)]
  89. corners = []
  90. for sf, sp in ((size, size), (-size, size), (-size, -size), (size, -size)):
  91. pt3 = [freq[i] * sf + phase[i] * sp for i in range(3)]
  92. corners.append((pt3[ax0], pt3[ax1]))
  93. return corners
  94. def _generate_noise_image(w: int = 256, h: int = 256) -> QImage:
  95. rng = np.random.default_rng(42)
  96. data = rng.integers(0, 70, (h, w), dtype=np.uint8)
  97. cy, cx = h / 2, w / 2
  98. Y, X = np.ogrid[:h, :w]
  99. dist = np.sqrt(((X - cx) / cx) ** 2 + ((Y - cy) / cy) ** 2)
  100. mask = np.clip(1.0 - dist * 0.85, 0.05, 1.0)
  101. data = (data * mask).astype(np.uint8)
  102. alpha = np.full((h, w), 200, dtype=np.uint8)
  103. rgba = np.stack([data, data, data, alpha], axis=-1)
  104. img = QImage(rgba.tobytes(), w, h, 4 * w, QImage.Format_RGBA8888)
  105. return img.copy()
  106. # ==============================================================================
  107. class MriViewerWidget(QWidget):
  108. """
  109. Dark MRI image viewer - pure QPainter.
  110. Ctrl + left-drag moves the slice square within the viewer plane.
  111. The widget emits slice_offset_changed(ax0, ax1, d0, d1) so ScanningTab
  112. can accumulate a shared 3-D offset vector and push it back to all viewers.
  113. """
  114. # slice_offset_changed(physical_axis_u, physical_axis_v, delta_u, delta_v)
  115. slice_offset_changed = Signal(int, int, float, float)
  116. # rotation_delta(drx_deg, dry_deg) - Ctrl+drag gamedev-style rotation
  117. rotation_delta = Signal(float, float)
  118. _IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
  119. def __init__(self, label: str = "Axial", parent: QWidget | None = None) -> None:
  120. super().__init__(parent)
  121. self._label = label
  122. self._scan_y = 0
  123. self._active_scan = False
  124. self._rot_matrix = [row[:] for row in self._IDENTITY]
  125. self._slice_offset = [0.0, 0.0, 0.0] # 3-D offset in logical [-1, 1] space
  126. self._drag_pos = None # QPointF while dragging
  127. self._drag_mode = None # 'move' | 'rotate'
  128. self._noise = _generate_noise_image()
  129. self.setMinimumSize(120, 120)
  130. self.setMouseTracking(True) # needed for cursor updates without button
  131. # -- public API ---------------------------------------------------------
  132. def set_scanning(self, active: bool) -> None:
  133. self._active_scan = active
  134. self.update()
  135. def set_rotation_matrix(self, R: list) -> None:
  136. self._rot_matrix = R
  137. self.update()
  138. def set_slice_offset(self, offset: list) -> None:
  139. self._slice_offset = list(offset)
  140. self.update()
  141. def advance_scanline(self, phase: int) -> None:
  142. if self.height() == 0:
  143. return
  144. self._scan_y = int(phase / _SCAN_SWEEP_DURATION * self.height())
  145. self.update()
  146. # -- mouse interaction --------------------------------------------------
  147. # LMB drag -> move slice (translate)
  148. # Ctrl + LMB drag -> rotate slice (gamedev orbit: dx=Ry yaw, dy=Rx pitch)
  149. def _pixel_scale(self) -> float:
  150. return min(self.width(), self.height()) * 0.46
  151. def _hover_cursor(self, modifiers) -> Qt.CursorShape:
  152. return Qt.OpenHandCursor if (modifiers & Qt.ControlModifier) else Qt.SizeAllCursor
  153. def mousePressEvent(self, event) -> None: # noqa: N802
  154. if event.button() == Qt.LeftButton:
  155. self._drag_pos = event.position()
  156. self._drag_mode = "rotate" if (event.modifiers() & Qt.ControlModifier) else "move"
  157. self.setCursor(Qt.ClosedHandCursor if self._drag_mode == "rotate" else Qt.SizeAllCursor)
  158. event.accept()
  159. else:
  160. super().mousePressEvent(event)
  161. def mouseMoveEvent(self, event) -> None: # noqa: N802
  162. if not (event.buttons() & Qt.LeftButton):
  163. self.setCursor(self._hover_cursor(event.modifiers()))
  164. if self._drag_pos is not None and (event.buttons() & Qt.LeftButton):
  165. pos = event.position()
  166. dx = pos.x() - self._drag_pos.x()
  167. dy = pos.y() - self._drag_pos.y()
  168. if self._drag_mode == "move":
  169. scale = self._pixel_scale()
  170. if scale > 0:
  171. ax0, ax1 = _VIEWER_AXES[self._label]
  172. self.slice_offset_changed.emit(ax0, ax1, dx / scale, -dy / scale)
  173. else: # rotate - gamedev FPS orbit
  174. # horizontal drag -> yaw (Ry)
  175. # vertical drag -> pitch (Rx); screen-down = positive pitch
  176. self.rotation_delta.emit(
  177. dy * _ROT_SENSITIVITY, # drx
  178. dx * _ROT_SENSITIVITY, # dry
  179. )
  180. self._drag_pos = pos
  181. event.accept()
  182. else:
  183. super().mouseMoveEvent(event)
  184. def mouseReleaseEvent(self, event) -> None: # noqa: N802
  185. if event.button() == Qt.LeftButton and self._drag_pos is not None:
  186. self._drag_pos = None
  187. self._drag_mode = None
  188. self.setCursor(self._hover_cursor(event.modifiers()))
  189. event.accept()
  190. else:
  191. super().mouseReleaseEvent(event)
  192. # -- painting -----------------------------------------------------------
  193. def paintEvent(self, event) -> None: # noqa: N802
  194. p = QPainter(self)
  195. rc = self.rect()
  196. w, h = rc.width(), rc.height()
  197. # 1 - background
  198. p.fillRect(rc, QColor(_IMAGE_BG))
  199. # 2 - noise texture
  200. p.setOpacity(0.9)
  201. p.drawImage(rc, self._noise)
  202. p.setOpacity(1.0)
  203. # 3 - crosshair
  204. cx, cy = w // 2, h // 2
  205. p.setPen(QPen(QColor("#444444"), 1))
  206. p.drawLine(cx - int(w * 0.30), cy, cx + int(w * 0.30), cy)
  207. p.drawLine(cx, cy - int(h * 0.30), cx, cy + int(h * 0.30))
  208. # 4 - scale ticks (3 per edge at 25/50/75 %)
  209. p.setPen(QPen(QColor("#888888"), 1))
  210. tick = 8
  211. for frac in (0.25, 0.50, 0.75):
  212. tx, ty = int(w * frac), int(h * frac)
  213. p.drawLine(tx, 0, tx, tick)
  214. p.drawLine(tx, h - tick, tx, h)
  215. p.drawLine(0, ty, tick, ty)
  216. p.drawLine(w - tick, ty, w, ty)
  217. # 5 - dim outer border
  218. p.setPen(QPen(QColor(_ACCENT_DIM), 1))
  219. p.drawRect(rc.adjusted(2, 2, -2, -2))
  220. # 6 - projected slice square (with 3-D offset applied)
  221. scale = self._pixel_scale()
  222. ax0, ax1 = _VIEWER_AXES[self._label]
  223. off_u = self._slice_offset[ax0]
  224. off_v = self._slice_offset[ax1]
  225. origin_x = cx + off_u * scale
  226. origin_y = cy - off_v * scale # Y-up -> Y-down
  227. corners_uv = _project_slice_quad(self._rot_matrix, self._label)
  228. pts = QPolygonF([
  229. QPointF(origin_x + u * scale, origin_y - v * scale)
  230. for u, v in corners_uv
  231. ])
  232. # semi-transparent fill
  233. p.setPen(Qt.NoPen)
  234. p.setBrush(QColor(240, 192, 64, 45))
  235. p.drawPolygon(pts)
  236. # solid border
  237. pen = QPen(QColor(_ACCENT), 2)
  238. pen.setJoinStyle(Qt.MiterJoin)
  239. p.setPen(pen)
  240. p.setBrush(Qt.NoBrush)
  241. p.drawPolygon(pts)
  242. # 7 - scan sweep stripe
  243. if self._active_scan:
  244. sy = self._scan_y
  245. grad = QLinearGradient(0, sy, w, sy)
  246. grad.setColorAt(0.0, QColor(0, 0, 0, 0))
  247. grad.setColorAt(0.5, QColor(255, 255, 255, 96))
  248. grad.setColorAt(1.0, QColor(0, 0, 0, 0))
  249. p.fillRect(0, max(0, sy - 2), w, 5, grad)
  250. # 8 - orientation label
  251. font = QFont("Arial", 11, QFont.Bold)
  252. p.setFont(font)
  253. p.setPen(Qt.white)
  254. p.drawText(rc.adjusted(8, 4, 0, 0), Qt.AlignTop | Qt.AlignLeft, self._label)
  255. p.end()
  256. # ==============================================================================
  257. class ProtocolListWidget(QWidget):
  258. protocol_selected = Signal(str)
  259. def __init__(self, parent: QWidget | None = None) -> None:
  260. super().__init__(parent)
  261. self.setStyleSheet(f"background: {_PANEL_BG};")
  262. lay = QVBoxLayout(self)
  263. lay.setContentsMargins(4, 8, 4, 4)
  264. lay.setSpacing(4)
  265. header = QLabel("Протоколы")
  266. header.setStyleSheet(
  267. "color: #aaaaaa; font-weight: bold; font-size: 11px; background: transparent;"
  268. )
  269. lay.addWidget(header)
  270. self._list = QListWidget()
  271. self._list.setStyleSheet(f"""
  272. QListWidget {{
  273. background: {_PANEL_BG};
  274. color: #ffffff;
  275. border: 1px solid #3a3a3a;
  276. font-size: 12px;
  277. outline: 0;
  278. }}
  279. QListWidget::item {{
  280. padding: 6px 8px;
  281. border-bottom: 1px solid #333333;
  282. }}
  283. QListWidget::item:selected {{
  284. background: {_ORANGE_SEL};
  285. color: #ffffff;
  286. }}
  287. QListWidget::item:hover:!selected {{
  288. background: #3a3a3a;
  289. }}
  290. """)
  291. for name in _PROTOCOLS:
  292. self._list.addItem(name)
  293. self._list.setCurrentRow(0)
  294. self._list.currentTextChanged.connect(self.protocol_selected)
  295. lay.addWidget(self._list, stretch=1)
  296. # ==============================================================================
  297. class _ScanPipelineWorker(QThread):
  298. """
  299. Thin worker: sends everything to the orchestrator via POST /scan/ and
  300. polls GET /scenario/{job_id} until done.
  301. The orchestrator is responsible for:
  302. - Forwarding the .seq file to seq-interp for interpretation
  303. - Running the full measurement scenario (spectrometer, reconstructor, …)
  304. The GUI never calls seq-interp or any other microservice directly.
  305. """
  306. progress = Signal(str)
  307. job_started = Signal(str) # job_id received from orchestrator
  308. finished = Signal(str)
  309. error = Signal(str)
  310. raw_data_ready = Signal(str) # absolute path to temp JSON file
  311. def __init__(
  312. self,
  313. seq_file_path: str | None,
  314. seq_info: dict | None,
  315. orchestrator_url: str,
  316. scenario_id: str = "full_pipeline",
  317. protocol: str = "",
  318. parent=None,
  319. ) -> None:
  320. super().__init__(parent)
  321. self._seq_file = seq_file_path
  322. self._seq_info = dict(seq_info) if seq_info else {}
  323. self._scenario_id = scenario_id
  324. self._protocol = protocol
  325. from src.clients.orchestrator_client import OrchestratorClient
  326. self._orch = OrchestratorClient(orchestrator_url)
  327. # -- main run -----------------------------------------------------------
  328. def run(self) -> None:
  329. try:
  330. self._run_pipeline()
  331. except Exception as exc:
  332. if not self.isInterruptionRequested():
  333. self.error.emit(str(exc))
  334. def _run_pipeline(self) -> None:
  335. # 1. Quick orchestrator reachability check
  336. self.progress.emit("Проверка оркестратора...")
  337. if not self._orch.healthcheck():
  338. raise RuntimeError("Оркестратор недоступен — проверьте, что сервис запущен")
  339. # 2. Submit the scan job — orchestrator handles interpretation + pipeline
  340. self.progress.emit(
  341. f"Отправка задания в оркестратор "
  342. f"[сценарий: {self._scenario_id}, ИП: {self._protocol or '—'}]…"
  343. )
  344. job_id = self._orch.scan(
  345. seq_file_path=self._seq_file or None,
  346. seq_info=self._seq_info or None,
  347. scenario_id=self._scenario_id,
  348. protocol=self._protocol,
  349. )
  350. self.job_started.emit(job_id)
  351. self.progress.emit(f" Job {job_id[:8]}… запущен, ожидание результата…")
  352. # 3. Poll until done (orchestrator runs everything in background)
  353. def _on_status(status: str) -> None:
  354. self.progress.emit(f" [{status}]")
  355. final = self._orch.poll_scan(
  356. job_id,
  357. timeout=300.0,
  358. poll_interval=2.0,
  359. progress_cb=_on_status,
  360. interrupted_fn=self.isInterruptionRequested,
  361. )
  362. # 4. Extract raw measurement data from step results
  363. steps = final.get("steps", [])
  364. meas_id = None
  365. raw_data = None
  366. for step in steps:
  367. name = step.get("name")
  368. res = step.get("result") or {}
  369. if name == "start_measurement":
  370. meas_id = res.get("measurement_id")
  371. if name == "fetch_data":
  372. raw_data = res.get("data")
  373. self.progress.emit(f" Сканирование завершено (meas_id={meas_id})")
  374. # 5. Persist raw data for Spectroscopy tab
  375. is_stub = str(meas_id) in ("", "None", "meas_stub") or meas_id is None
  376. if raw_data and not is_stub:
  377. raw_path = self._save_raw(raw_data, meas_id)
  378. if raw_path:
  379. self.raw_data_ready.emit(raw_path)
  380. self.finished.emit(f"job завершён (meas_id={meas_id})")
  381. # -- raw data persistence -----------------------------------------------
  382. def _save_raw(self, data, meas_id) -> str | None:
  383. import tempfile, time as _t
  384. fname = f"scan_raw_{meas_id}_{int(_t.time())}.json"
  385. fpath = os.path.join(tempfile.gettempdir(), fname)
  386. try:
  387. with open(fpath, "w", encoding="utf-8") as fh:
  388. json.dump(data, fh)
  389. self.progress.emit(f" Данные сохранены: {fname}")
  390. return fpath
  391. except Exception as exc:
  392. self.progress.emit(f" Не удалось сохранить данные: {exc}")
  393. return None
  394. # ==============================================================================
  395. class ScanningTab(QWidget):
  396. """
  397. Operator-facing scanning tab.
  398. Bottom QTabWidget tabs match Siemens syngo layout:
  399. Основные | Контраст | Разрешение | Геометрия | Система
  400. "Геометрия" holds orientation presets + Rx/Ry/Rz spinboxes + live 3x3 matrix.
  401. """
  402. scan_job_started = Signal(str) # job_id — forwarded to ScannerTab for queue monitoring
  403. raw_data_ready = Signal(str) # absolute path to temp JSON file
  404. def __init__(self, parent: QWidget | None = None) -> None:
  405. super().__init__(parent)
  406. self.setStyleSheet(f"background: {_BG_DARK};")
  407. self._viewers: list[MriViewerWidget] = []
  408. self._scan_tick: int = 0
  409. self._seq_info: dict | None = None
  410. self._seq_file_path: str | None = None
  411. self._orchestrator_url: str = "http://localhost:1717"
  412. self._scan_worker: _ScanPipelineWorker | None = None
  413. self._active_protocol: str = _PROTOCOLS[0]
  414. self._scenario_id: str = "full_pipeline"
  415. self._slice_offset: list[float] = [0.0, 0.0, 0.0]
  416. root = QVBoxLayout(self)
  417. root.setContentsMargins(0, 0, 0, 0)
  418. root.setSpacing(0)
  419. vsplit = QSplitter(Qt.Vertical)
  420. vsplit.setStyleSheet("QSplitter::handle { background: #333344; height: 3px; }")
  421. vsplit.addWidget(self._build_upper_area())
  422. vsplit.addWidget(self._build_bottom_panel())
  423. vsplit.setSizes([700, 140])
  424. vsplit.setChildrenCollapsible(False)
  425. root.addWidget(vsplit, stretch=1)
  426. self._setup_animation()
  427. self._update_matrix_display()
  428. self._update_slice_display()
  429. self._update_scan_ready_state()
  430. # -- public API ---------------------------------------------------------
  431. def apply_seq_info(self, info_dict: dict) -> None:
  432. """Receive exported sequence info from SeqInterpTab and auto-start scan."""
  433. self._seq_info = dict(info_dict)
  434. self._seq_file_path = None
  435. self._update_scan_ready_state()
  436. label = info_dict.get("infostr") or "sequence"
  437. self._log(f"Получены параметры последовательности: {label}", "INFO")
  438. if not self._btn_scan.isChecked():
  439. self._btn_scan.setChecked(True)
  440. def set_orchestrator_url(self, url: str) -> None:
  441. self._orchestrator_url = url
  442. # -- layout builders ----------------------------------------------------
  443. def _build_upper_area(self) -> QWidget:
  444. container = QWidget()
  445. container.setStyleSheet(f"background: {_BG_DARK};")
  446. lay = QVBoxLayout(container)
  447. lay.setContentsMargins(0, 0, 0, 0)
  448. lay.setSpacing(0)
  449. hsplit = QSplitter(Qt.Horizontal)
  450. hsplit.setStyleSheet("QSplitter::handle { background: #333344; width: 3px; }")
  451. hsplit.addWidget(self._build_protocol_panel())
  452. hsplit.addWidget(self._build_image_grid())
  453. hsplit.setSizes([220, 980])
  454. hsplit.setChildrenCollapsible(False)
  455. lay.addWidget(hsplit)
  456. return container
  457. def _build_protocol_panel(self) -> QWidget:
  458. container = QWidget()
  459. container.setStyleSheet(f"background: {_PANEL_BG};")
  460. lay = QVBoxLayout(container)
  461. lay.setContentsMargins(0, 0, 0, 0)
  462. lay.setSpacing(0)
  463. self._protocol_list = ProtocolListWidget()
  464. self._protocol_list.protocol_selected.connect(self._on_protocol_selected)
  465. lay.addWidget(self._protocol_list, stretch=1)
  466. sep = QFrame()
  467. sep.setFrameShape(QFrame.HLine)
  468. sep.setFixedHeight(1)
  469. sep.setStyleSheet("background: #3a3a4a; border: none;")
  470. lay.addWidget(sep)
  471. # -- Scenario selector ------------------------------------------------
  472. scenario_container = QWidget()
  473. scenario_container.setStyleSheet(f"background: {_PANEL_BG};")
  474. sc_lay = QVBoxLayout(scenario_container)
  475. sc_lay.setContentsMargins(6, 6, 6, 4)
  476. sc_lay.setSpacing(4)
  477. sc_header = QLabel("Сценарий оркестратора")
  478. sc_header.setStyleSheet(
  479. "color: #7777aa; font-size: 10px; font-weight: bold; background: transparent;"
  480. )
  481. sc_lay.addWidget(sc_header)
  482. sc_row = QHBoxLayout()
  483. sc_row.setSpacing(4)
  484. _combo_style = (
  485. "QComboBox {"
  486. f" background: {_BTN_BG}; color: #ccccee;"
  487. " border: 1px solid #444466; border-radius: 3px;"
  488. " font-size: 11px; padding: 3px 6px;"
  489. "}"
  490. "QComboBox::drop-down { border: none; width: 16px; }"
  491. "QComboBox QAbstractItemView {"
  492. f" background: {_BTN_BG}; color: #ccccee; border: 1px solid #444466;"
  493. " selection-background-color: #e65100;"
  494. "}"
  495. )
  496. self._scenario_combo = QComboBox()
  497. self._scenario_combo.setStyleSheet(_combo_style)
  498. self._scenario_combo.setToolTip("Тип сценария, который будет запущен в оркестраторе")
  499. self._scenario_combo.addItem("full_pipeline") # default
  500. self._scenario_combo.currentTextChanged.connect(self._on_scenario_selected)
  501. sc_row.addWidget(self._scenario_combo, stretch=1)
  502. btn_refresh_sc = QPushButton("↻")
  503. btn_refresh_sc.setFixedSize(24, 24)
  504. btn_refresh_sc.setToolTip("Получить список сценариев из оркестратора")
  505. btn_refresh_sc.setStyleSheet(
  506. f"QPushButton {{ background: {_BTN_BG}; color: #7777aa;"
  507. " border: 1px solid #444466; border-radius: 3px; font-size: 13px; }}"
  508. "QPushButton:hover { color: #ffffff; background: #303050; }"
  509. )
  510. btn_refresh_sc.clicked.connect(self._on_refresh_scenarios)
  511. sc_row.addWidget(btn_refresh_sc)
  512. sc_lay.addLayout(sc_row)
  513. lay.addWidget(scenario_container)
  514. sep2 = QFrame()
  515. sep2.setFrameShape(QFrame.HLine)
  516. sep2.setFixedHeight(1)
  517. sep2.setStyleSheet("background: #3a3a4a; border: none;")
  518. lay.addWidget(sep2)
  519. # -- .seq file loader -------------------------------------------------
  520. btn_load = QPushButton("Загрузить .seq…")
  521. btn_load.setToolTip("Выбрать готовый .seq файл для запуска полного пайплайна")
  522. btn_load.setStyleSheet(
  523. "QPushButton {"
  524. f" background: {_BTN_BG}; color: #aaaacc;"
  525. " border: 1px solid #444466; border-radius: 3px;"
  526. " font-size: 11px; padding: 5px 8px; margin: 6px 6px 2px 6px;"
  527. "}"
  528. "QPushButton:hover { background: #303050; color: #ffffff; }"
  529. )
  530. btn_load.clicked.connect(self._on_load_seq_clicked)
  531. lay.addWidget(btn_load)
  532. self._lbl_seq_file = QLabel("Файл не выбран")
  533. self._lbl_seq_file.setWordWrap(True)
  534. self._lbl_seq_file.setStyleSheet(
  535. "color: #555577; font-size: 10px; background: transparent;"
  536. "padding: 0 8px 6px 8px;"
  537. )
  538. lay.addWidget(self._lbl_seq_file)
  539. return container
  540. def _build_image_grid(self) -> QWidget:
  541. container = QWidget()
  542. container.setStyleSheet(f"background: {_BG_DARK};")
  543. grid = QGridLayout(container)
  544. grid.setContentsMargins(6, 6, 6, 6)
  545. grid.setSpacing(6)
  546. for col, lbl in enumerate(("Axial", "Cor", "Sag")):
  547. viewer = MriViewerWidget(label=lbl)
  548. viewer.slice_offset_changed.connect(self._on_slice_offset_changed)
  549. viewer.rotation_delta.connect(self._on_rotation_delta)
  550. grid.addWidget(viewer, 0, col)
  551. grid.setColumnStretch(col, 1)
  552. self._viewers.append(viewer)
  553. grid.setRowStretch(0, 1)
  554. return container
  555. def _build_bottom_panel(self) -> QWidget:
  556. panel = QWidget()
  557. panel.setStyleSheet("background: #16162a; border-top: 1px solid #333355;")
  558. outer = QVBoxLayout(panel)
  559. outer.setContentsMargins(0, 0, 0, 0)
  560. outer.setSpacing(0)
  561. # -- parameter tabs -------------------------------------------------
  562. self._param_tabs = QTabWidget()
  563. self._param_tabs.setStyleSheet("""
  564. QTabWidget::pane { border: none; background: #16162a; }
  565. QTabBar::tab {
  566. background: #1e1e38; color: #aaaacc;
  567. padding: 5px 14px; border: 1px solid #333355;
  568. border-bottom: none; margin-right: 2px; font-size: 11px;
  569. }
  570. QTabBar::tab:selected { background: #252545; color: #ffffff; }
  571. QTabBar::tab:hover:!selected { background: #222240; }
  572. """)
  573. placeholder_tabs = ["Основные", "Контраст", "Разрешение", "Система"]
  574. for name in placeholder_tabs:
  575. w = QLabel(f"[ {name} - TODO ]")
  576. w.setAlignment(Qt.AlignCenter)
  577. w.setStyleSheet("color: #555577; background: #16162a;")
  578. self._param_tabs.addTab(w, name)
  579. geo_tab = self._build_geometry_tab()
  580. self._param_tabs.addTab(geo_tab, "Геометрия")
  581. log_tab = self._build_log_tab()
  582. self._param_tabs.addTab(log_tab, "Лог")
  583. self._param_tabs.setCurrentWidget(geo_tab)
  584. outer.addWidget(self._param_tabs, stretch=1)
  585. # -- bottom action bar ----------------------------------------------
  586. action_bar = QWidget()
  587. action_bar.setStyleSheet("background: #16162a; border-top: 1px solid #2a2a4a;")
  588. action_lay = QHBoxLayout(action_bar)
  589. action_lay.setContentsMargins(12, 6, 12, 6)
  590. action_lay.setSpacing(12)
  591. self._status_label = QLabel("Нет данных")
  592. self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
  593. action_lay.addWidget(self._status_label)
  594. action_lay.addStretch()
  595. self._btn_scan = QPushButton("Сканировать")
  596. self._btn_scan.setCheckable(True)
  597. self._btn_scan.setMinimumWidth(140)
  598. self._btn_scan.setStyleSheet(
  599. "QPushButton {"
  600. " background: #1a3a1a; color: #88ee88;"
  601. " border: 1px solid #336633; border-radius: 4px;"
  602. " font-size: 12px; font-weight: bold; padding: 5px 16px;"
  603. "}"
  604. "QPushButton:checked {"
  605. " background: #3a1a1a; color: #ee8888; border-color: #663333;"
  606. "}"
  607. "QPushButton:hover:!checked { background: #1e4a1e; }"
  608. )
  609. self._btn_scan.toggled.connect(self._on_scan_toggled)
  610. action_lay.addWidget(self._btn_scan)
  611. outer.addWidget(action_bar)
  612. return panel
  613. def _build_geometry_tab(self) -> QWidget:
  614. w = QWidget()
  615. w.setStyleSheet("background: #16162a;")
  616. lay = QHBoxLayout(w)
  617. lay.setContentsMargins(12, 8, 12, 8)
  618. lay.setSpacing(16)
  619. # -- orientation presets --------------------------------------------
  620. preset_group = QGroupBox("Ориентация")
  621. preset_group.setStyleSheet(self._group_style())
  622. preset_lay = QVBoxLayout(preset_group)
  623. preset_lay.setSpacing(4)
  624. self._btn_group = QButtonGroup(self)
  625. self._btn_group.setExclusive(True)
  626. self._preset_buttons: dict[str, QPushButton] = {}
  627. for name in ("Axial", "Coronal", "Sagittal"):
  628. btn = QPushButton(name)
  629. btn.setCheckable(True)
  630. btn.setStyleSheet(self._preset_btn_style())
  631. preset_lay.addWidget(btn)
  632. self._btn_group.addButton(btn)
  633. self._preset_buttons[name] = btn
  634. btn.clicked.connect(lambda checked, n=name: self._on_preset(n))
  635. self._preset_buttons["Axial"].setChecked(True)
  636. lay.addWidget(preset_group)
  637. # -- rotation angles ------------------------------------------------
  638. rot_group = QGroupBox("Поворот")
  639. rot_group.setStyleSheet(self._group_style())
  640. form = QFormLayout(rot_group)
  641. form.setSpacing(6)
  642. form.setContentsMargins(8, 4, 8, 4)
  643. spin_style = (
  644. "QDoubleSpinBox {"
  645. " background: #252540; color: #ddddff;"
  646. " border: 1px solid #445; border-radius: 3px; padding: 2px 4px;"
  647. "}"
  648. "QDoubleSpinBox:focus { border-color: #f0c040; }"
  649. )
  650. lbl_style = "color: #aaaacc; font-size: 11px;"
  651. self._spin_rx = QDoubleSpinBox()
  652. self._spin_ry = QDoubleSpinBox()
  653. self._spin_rz = QDoubleSpinBox()
  654. for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
  655. spin.setRange(-180.0, 180.0)
  656. spin.setSingleStep(1.0)
  657. spin.setDecimals(1)
  658. spin.setSuffix(" deg")
  659. spin.setStyleSheet(spin_style)
  660. spin.setMinimumWidth(90)
  661. spin.valueChanged.connect(self._on_rotation_changed)
  662. for lbl_text, spin in (("Rx", self._spin_rx), ("Ry", self._spin_ry), ("Rz", self._spin_rz)):
  663. lbl = QLabel(lbl_text)
  664. lbl.setStyleSheet(lbl_style)
  665. form.addRow(lbl, spin)
  666. lay.addWidget(rot_group)
  667. # -- rotation matrix display ----------------------------------------
  668. matrix_group = QGroupBox("Матрица поворота")
  669. matrix_group.setStyleSheet(self._group_style())
  670. matrix_lay = QVBoxLayout(matrix_group)
  671. matrix_lay.setContentsMargins(8, 4, 8, 4)
  672. self._matrix_label = QLabel()
  673. self._matrix_label.setFont(QFont("Courier New", 10))
  674. self._matrix_label.setStyleSheet("color: #99ccff; background: transparent;")
  675. self._matrix_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
  676. matrix_lay.addWidget(self._matrix_label)
  677. lay.addWidget(matrix_group)
  678. lay.addStretch()
  679. return w
  680. def _build_log_tab(self) -> QWidget:
  681. w = QWidget()
  682. w.setStyleSheet("background: #16162a;")
  683. lay = QVBoxLayout(w)
  684. lay.setContentsMargins(6, 6, 6, 4)
  685. lay.setSpacing(4)
  686. self._log_view = QTextEdit()
  687. self._log_view.setReadOnly(True)
  688. self._log_view.setFont(QFont("Courier New", 9))
  689. self._log_view.setStyleSheet(
  690. "QTextEdit {"
  691. " background: #0e0e1c; color: #aaaacc;"
  692. " border: 1px solid #2a2a4a; border-radius: 3px;"
  693. "}"
  694. )
  695. lay.addWidget(self._log_view, stretch=1)
  696. btn_clear = QPushButton("Очистить")
  697. btn_clear.setFixedWidth(90)
  698. btn_clear.setStyleSheet(
  699. "QPushButton {"
  700. f" background: {_BTN_BG}; color: #777799;"
  701. " border: 1px solid #333355; border-radius: 3px;"
  702. " font-size: 10px; padding: 3px 8px;"
  703. "}"
  704. "QPushButton:hover { color: #aaaacc; }"
  705. )
  706. btn_clear.clicked.connect(self._log_view.clear)
  707. lay.addWidget(btn_clear, alignment=Qt.AlignRight)
  708. return w
  709. # level: "INFO" | "WARN" | "ERR"
  710. _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
  711. def _log(self, msg: str, level: str = "INFO") -> None:
  712. """Append [LEVEL] HH:MM:SS message to the log and auto-scroll."""
  713. if not hasattr(self, "_log_view"):
  714. return
  715. from datetime import datetime
  716. ts = datetime.now().strftime("%H:%M:%S")
  717. color = self._LOG_COLORS.get(level, "#aaaacc")
  718. line = (
  719. f"<span style='color:#555577'>{ts}</span>"
  720. f" <span style='color:{color}'>[{level}]</span>"
  721. f" {msg}"
  722. )
  723. self._log_view.append(line)
  724. sb = self._log_view.verticalScrollBar()
  725. sb.setValue(sb.maximum())
  726. @staticmethod
  727. def _group_style() -> str:
  728. return (
  729. "QGroupBox {"
  730. " color: #8888aa; font-size: 10px; font-weight: bold;"
  731. " border: 1px solid #333355; border-radius: 4px; margin-top: 6px;"
  732. "}"
  733. "QGroupBox::title { subcontrol-origin: margin; left: 6px; top: -1px; }"
  734. )
  735. @staticmethod
  736. def _preset_btn_style() -> str:
  737. return (
  738. "QPushButton {"
  739. f" background: {_BTN_BG}; color: #ccccee;"
  740. " border: 1px solid #444466; border-radius: 3px;"
  741. " font-size: 11px; padding: 4px 10px;"
  742. "}"
  743. "QPushButton:checked { background: #e65100; color: #ffffff; border-color: #ff7733; }"
  744. "QPushButton:hover:!checked { background: #303050; }"
  745. )
  746. # -- animation ----------------------------------------------------------
  747. def _setup_animation(self) -> None:
  748. self._scan_timer = QTimer(self)
  749. self._scan_timer.setInterval(_SCAN_LINE_INTERVAL_MS)
  750. self._scan_timer.timeout.connect(self._on_tick)
  751. def _on_tick(self) -> None:
  752. self._scan_tick += 1
  753. offset = _SCAN_SWEEP_DURATION // max(len(self._viewers), 1)
  754. for i, viewer in enumerate(self._viewers):
  755. phase = (self._scan_tick + i * offset) % _SCAN_SWEEP_DURATION
  756. viewer.advance_scanline(phase)
  757. # -- rotation logic -----------------------------------------------------
  758. def _on_preset(self, name: str) -> None:
  759. rx, ry, rz = _PRESETS[name]
  760. for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
  761. spin.blockSignals(True)
  762. self._spin_rx.setValue(rx)
  763. self._spin_ry.setValue(ry)
  764. self._spin_rz.setValue(rz)
  765. for spin in (self._spin_rx, self._spin_ry, self._spin_rz):
  766. spin.blockSignals(False)
  767. self._on_rotation_changed()
  768. def _on_rotation_changed(self) -> None:
  769. self._update_matrix_display()
  770. self._update_slice_display()
  771. self._sync_preset_buttons()
  772. def _compute_rotation_matrix(self) -> list:
  773. return _euler_to_matrix(
  774. self._spin_rx.value(),
  775. self._spin_ry.value(),
  776. self._spin_rz.value(),
  777. )
  778. def _update_matrix_display(self) -> None:
  779. R = self._compute_rotation_matrix()
  780. lines = []
  781. for row in R:
  782. lines.append(" ".join(f"{v:+.3f}" for v in row))
  783. self._matrix_label.setText("\n".join(lines))
  784. def _update_slice_display(self) -> None:
  785. """Push current rotation matrix and offset to all viewers."""
  786. R = self._compute_rotation_matrix()
  787. for v in self._viewers:
  788. v.set_rotation_matrix(R)
  789. v.set_slice_offset(self._slice_offset)
  790. def _on_slice_offset_changed(self, ax0: int, ax1: int, d0: float, d1: float) -> None:
  791. """Accumulate drag delta from any viewer into the shared 3-D offset."""
  792. self._slice_offset[ax0] = max(-0.95, min(0.95, self._slice_offset[ax0] + d0))
  793. self._slice_offset[ax1] = max(-0.95, min(0.95, self._slice_offset[ax1] + d1))
  794. R = self._compute_rotation_matrix()
  795. for v in self._viewers:
  796. v.set_slice_offset(self._slice_offset)
  797. v.set_rotation_matrix(R)
  798. def _on_rotation_delta(self, drx: float, dry: float) -> None:
  799. """Gamedev-style Ctrl+drag: apply incremental Rx/Ry rotation from any viewer."""
  800. def _wrap(val: float) -> float:
  801. while val > 180.0: val -= 360.0
  802. while val < -180.0: val += 360.0
  803. return val
  804. for spin, delta in ((self._spin_rx, drx), (self._spin_ry, dry)):
  805. spin.blockSignals(True)
  806. spin.setValue(_wrap(spin.value() + delta))
  807. spin.blockSignals(False)
  808. self._on_rotation_changed()
  809. def _sync_preset_buttons(self) -> None:
  810. """Check if current angles match a known preset; highlight button if so."""
  811. rx = round(self._spin_rx.value(), 1)
  812. ry = round(self._spin_ry.value(), 1)
  813. rz = round(self._spin_rz.value(), 1)
  814. matched = None
  815. for name, (prx, pry, prz) in _PRESETS.items():
  816. if (rx, ry, rz) == (prx, pry, prz):
  817. matched = name
  818. break
  819. for name, btn in self._preset_buttons.items():
  820. btn.blockSignals(True)
  821. btn.setChecked(name == matched)
  822. btn.blockSignals(False)
  823. # -- .seq file loading --------------------------------------------------
  824. def _on_load_seq_clicked(self) -> None:
  825. path, _ = QFileDialog.getOpenFileName(
  826. self,
  827. "Выбрать .seq файл",
  828. os.path.join(os.path.dirname(__file__), os.pardir, os.pardir),
  829. "Pulseq файлы (*.seq);;Все файлы (*)",
  830. )
  831. if not path:
  832. return
  833. self._seq_file_path = path
  834. self._seq_info = None
  835. fname = os.path.basename(path)
  836. self._lbl_seq_file.setText(fname)
  837. self._lbl_seq_file.setStyleSheet(
  838. "color: #e65100; font-size: 10px; background: transparent;"
  839. "padding: 0 8px 6px 8px;"
  840. )
  841. self._log(f"Загружен файл: {fname}", "INFO")
  842. self._update_scan_ready_state()
  843. # -- scan initiation ----------------------------------------------------
  844. def _on_scan_toggled(self, checked: bool) -> None:
  845. if checked:
  846. if self._scan_worker is not None and self._scan_worker.isRunning():
  847. return
  848. if self._seq_info is None and self._seq_file_path is None:
  849. QMessageBox.warning(
  850. self, "Нет данных",
  851. "Загрузите .seq файл кнопкой «Загрузить .seq…»\n"
  852. "или экспортируйте последовательность во вкладке «Sequence»."
  853. )
  854. self._btn_scan.setChecked(False)
  855. return
  856. info = dict(self._seq_info) if self._seq_info else {}
  857. if info:
  858. info["rotation_matrix"] = self._compute_rotation_matrix()
  859. info["slice_position"] = list(self._slice_offset)
  860. self._scan_worker = _ScanPipelineWorker(
  861. seq_file_path=self._seq_file_path,
  862. seq_info=info if info else None,
  863. orchestrator_url=self._orchestrator_url,
  864. scenario_id=self._scenario_id,
  865. protocol=self._active_protocol,
  866. parent=self,
  867. )
  868. self._scan_worker.job_started.connect(self.scan_job_started)
  869. self._scan_worker.finished.connect(self._on_scan_done)
  870. self._scan_worker.error.connect(self._on_scan_error)
  871. self._scan_worker.progress.connect(self._on_scan_progress)
  872. self._scan_worker.raw_data_ready.connect(self._on_raw_data_ready_log)
  873. self._scan_worker.raw_data_ready.connect(self.raw_data_ready)
  874. self._scan_worker.start()
  875. self._scan_timer.start()
  876. self._btn_scan.setText("Stop Стоп")
  877. self._status_label.setText("Инициализация пайплайна…")
  878. self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
  879. self._log("--- Запуск пайплайна сканирования ---", "INFO")
  880. for v in self._viewers:
  881. v.set_scanning(True)
  882. # Switch to Log tab so the user can follow progress
  883. self._param_tabs.setCurrentIndex(
  884. self._param_tabs.indexOf(self._log_view.parent())
  885. )
  886. else:
  887. self._scan_timer.stop()
  888. if self._scan_worker and self._scan_worker.isRunning():
  889. self._scan_worker.requestInterruption()
  890. self._btn_scan.setText("Run Сканировать")
  891. for v in self._viewers:
  892. v.set_scanning(False)
  893. self._update_scan_ready_state()
  894. def _on_raw_data_ready_log(self, path: str) -> None:
  895. self._log(f"Данные отправлены во вкладку Spectroscopy: {os.path.basename(path)}", "INFO")
  896. def _on_scan_progress(self, msg: str) -> None:
  897. self._status_label.setText(msg[:80])
  898. self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
  899. self._log(msg, "INFO")
  900. def _on_scan_done(self, msg: str) -> None:
  901. self._scan_timer.stop()
  902. self._status_label.setText("Готово")
  903. self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
  904. self._log(msg, "INFO")
  905. for v in self._viewers:
  906. v.set_scanning(False)
  907. self._btn_scan.setChecked(False)
  908. def _on_scan_error(self, err: str) -> None:
  909. self._scan_timer.stop()
  910. self._status_label.setText(f"Ошибка: {err[:70]}")
  911. self._status_label.setStyleSheet("color: #ee4444; font-size: 11px;")
  912. self._log(err, "ERR")
  913. for v in self._viewers:
  914. v.set_scanning(False)
  915. self._btn_scan.setChecked(False)
  916. def _update_scan_ready_state(self) -> None:
  917. if self._seq_file_path:
  918. fname = os.path.basename(self._seq_file_path)
  919. self._status_label.setText(f"Файл: {fname}")
  920. self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
  921. elif self._seq_info is not None:
  922. self._status_label.setText("Готово к сканированию")
  923. self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
  924. else:
  925. self._status_label.setText("Нет данных")
  926. self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
  927. # -- scenario selection -------------------------------------------------
  928. def _on_scenario_selected(self, name: str) -> None:
  929. if name:
  930. self._scenario_id = name
  931. def _on_refresh_scenarios(self) -> None:
  932. """Fetch available scenario IDs from the orchestrator."""
  933. import httpx
  934. try:
  935. r = httpx.get(
  936. f"{self._orchestrator_url}/scenario/list",
  937. timeout=5.0,
  938. )
  939. if r.is_success:
  940. scenarios: list[str] = r.json().get("scenarios", [])
  941. if scenarios:
  942. current = self._scenario_combo.currentText()
  943. self._scenario_combo.blockSignals(True)
  944. self._scenario_combo.clear()
  945. for s in scenarios:
  946. self._scenario_combo.addItem(s)
  947. # restore previous selection if still available
  948. idx = self._scenario_combo.findText(current)
  949. self._scenario_combo.setCurrentIndex(max(idx, 0))
  950. self._scenario_combo.blockSignals(False)
  951. self._scenario_id = self._scenario_combo.currentText()
  952. self._log(f"Сценарии загружены: {scenarios}", "INFO")
  953. else:
  954. self._log("Оркестратор вернул пустой список сценариев", "WARN")
  955. else:
  956. self._log(f"Ошибка загрузки сценариев: HTTP {r.status_code}", "WARN")
  957. except Exception as exc:
  958. self._log(f"Не удалось подключиться к оркестратору: {exc}", "WARN")
  959. # -- protocol selection -------------------------------------------------
  960. def _on_protocol_selected(self, name: str) -> None:
  961. self._active_protocol = name