Kaynağa Gözat

major update

spacexerq 3 hafta önce
ebeveyn
işleme
99f644dc3b

+ 58 - 12
apps/gui/src/app_window.py

@@ -9,7 +9,7 @@ from __future__ import annotations
 
 import os
 
-from PySide6.QtCore import Qt, QSize
+from PySide6.QtCore import Qt, QSize, QTimer
 from src import i18n
 from PySide6.QtWidgets import (
     QApplication,
@@ -32,13 +32,15 @@ from src.tabs.seq_interp_tab import SeqInterpTab
 from src.tabs.spectroscopy_tab import SpectroscopyTab
 
 _TAB_NAV_KEYS = [
+    "tab_nav_scanning",
     "tab_nav_sequence",
     "tab_nav_scanner",
-    "tab_nav_fid",
-    "tab_nav_scanning",
     "tab_nav_spectro",
+    "tab_nav_fid",
 ]
 
+_SPEC_TAB_IDX = 3  # index of the Spectroscopy tab
+
 _NAV_BG = "#0f0f1e"
 _NAV_H = 38
 _TAB_BTN_CSS = """
@@ -91,6 +93,26 @@ QToolBar {{
 }}
 """
 
+_SPEC_BTN_BLINK_CSS = """
+QPushButton {
+    background: transparent;
+    color: #e65100;
+    border: none;
+    border-bottom: 2px solid #e65100;
+    padding: 0px 20px;
+    font-size: 12px;
+    min-height: 36px;
+}
+QPushButton:checked {
+    color: #ffffff;
+    border-bottom: 2px solid #f0c040;
+}
+QPushButton:hover:!checked {
+    color: #ff7733;
+    background: #17172e;
+}
+"""
+
 
 class LFMRIWindow(QMainWindow):
     """Unified LF-MRI application window."""
@@ -133,19 +155,25 @@ class LFMRIWindow(QMainWindow):
         self._tabs = QTabWidget()
         self._tabs.tabBar().hide()
         self._tabs.setDocumentMode(True)
-        self._tabs.addTab(self._seq_tab,          i18n.tr(_TAB_NAV_KEYS[0]))
-        self._tabs.addTab(self._scanner_tab,       i18n.tr(_TAB_NAV_KEYS[1]))
-        self._tabs.addTab(self._fid_tab,           i18n.tr(_TAB_NAV_KEYS[2]))
-        self._tabs.addTab(self._scanning_tab,      i18n.tr(_TAB_NAV_KEYS[3]))
-        self._tabs.addTab(self._spectroscopy_tab,  i18n.tr(_TAB_NAV_KEYS[4]))
+        self._tabs.addTab(self._scanning_tab,      i18n.tr(_TAB_NAV_KEYS[0]))
+        self._tabs.addTab(self._seq_tab,           i18n.tr(_TAB_NAV_KEYS[1]))
+        self._tabs.addTab(self._scanner_tab,        i18n.tr(_TAB_NAV_KEYS[2]))
+        self._tabs.addTab(self._spectroscopy_tab,  i18n.tr(_TAB_NAV_KEYS[3]))
+        self._tabs.addTab(self._fid_tab,           i18n.tr(_TAB_NAV_KEYS[4]))
         self._tabs.currentChanged.connect(self._on_tab_changed)
         self.setCentralWidget(self._tabs)
 
         self._fid_tab.fid_seq_generated.connect(self._on_fid_generated)
         self._seq_tab.ready_for_scan.connect(self._on_ready_for_scan)
         self._scanning_tab.scan_job_started.connect(self._scanner_tab.attach_job)
+        self._scanning_tab.raw_data_ready.connect(self._on_scan_raw_data_ready)
         self._scanner_tab.scan_result_ready.connect(self._spectroscopy_tab.receive_scan_data)
 
+        self._spec_blink_on: bool = False
+        self._spec_blink_timer = QTimer(self)
+        self._spec_blink_timer.setInterval(700)
+        self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
+
         self.menuBar().hide()
         self._build_nav_bar()
         self._build_status_bar()
@@ -245,6 +273,8 @@ class LFMRIWindow(QMainWindow):
         self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}")
         if 0 <= index < len(self._nav_tab_buttons):
             self._nav_tab_buttons[index].setChecked(True)
+        if index == _SPEC_TAB_IDX:
+            self._stop_spec_blink()
 
     def _on_language_change(self, lang: str) -> None:
         i18n.set_language(lang)
@@ -257,23 +287,39 @@ class LFMRIWindow(QMainWindow):
         key = _TAB_NAV_KEYS[cur] if 0 <= cur < len(_TAB_NAV_KEYS) else "-"
         self.statusBar().showMessage(f"{i18n.tr('active_tab')}: {i18n.tr(key)}")
         for tab in (
+            self._scanning_tab,
             self._seq_tab,
             self._scanner_tab,
-            self._fid_tab,
-            self._scanning_tab,
             self._spectroscopy_tab,
+            self._fid_tab,
         ):
             if hasattr(tab, "retranslate_ui"):
                 tab.retranslate_ui()
 
     def _on_fid_generated(self, path: str) -> None:
         self._seq_tab.load_seq_file(path)
-        self._switch_tab(0)
+        self._switch_tab(1)
 
     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)
+        self._switch_tab(2)
+
+    def _on_scan_raw_data_ready(self, json_path: str) -> None:
+        self._spectroscopy_tab.receive_scan_data(json_path)
+        # Blink the Spectroscopy nav button until the user opens the tab
+        if self._tabs.currentIndex() != _SPEC_TAB_IDX:
+            self._spec_blink_on = False
+            self._spec_blink_timer.start()
+
+    def _tick_spec_blink(self) -> None:
+        self._spec_blink_on = not self._spec_blink_on
+        btn = self._nav_tab_buttons[_SPEC_TAB_IDX]
+        btn.setStyleSheet(_SPEC_BTN_BLINK_CSS if self._spec_blink_on else _TAB_BTN_CSS)
+
+    def _stop_spec_blink(self) -> None:
+        self._spec_blink_timer.stop()
+        self._nav_tab_buttons[_SPEC_TAB_IDX].setStyleSheet(_TAB_BTN_CSS)
 
 
 class _VSep(QFrame):

+ 377 - 24
apps/gui/src/tabs/scanning_tab.py

@@ -13,6 +13,7 @@ from __future__ import annotations
 
 import math
 import json
+import os
 import numpy as np
 
 from PySide6.QtCore import Qt, QThread, QTimer, Signal
@@ -21,9 +22,9 @@ from PySide6.QtGui import (
 )
 from PySide6.QtCore import QPointF
 from PySide6.QtWidgets import (
-    QButtonGroup, QDoubleSpinBox, QFormLayout, QGridLayout,
-    QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
-    QPushButton, QSplitter, QTabWidget, QVBoxLayout, QWidget,
+    QButtonGroup, QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QFrame,
+    QGridLayout, QGroupBox, QHBoxLayout, QLabel, QListWidget, QMessageBox,
+    QPushButton, QSplitter, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
 )
 
 try:
@@ -363,6 +364,113 @@ class ProtocolListWidget(QWidget):
 
 
 # ==============================================================================
+class _ScanPipelineWorker(QThread):
+    """
+    Full pipeline: health-check → load scenario (with seq_file / seq_info) →
+    run_all → poll → extract raw data → emit raw_data_ready.
+
+    The orchestrator is responsible for:
+      - Forwarding the .seq file to seq-interp for interpretation
+      - Running the full measurement scenario (spectrometer, reconstructor, …)
+
+    The GUI never calls seq-interp or any other microservice directly.
+    """
+
+    progress       = Signal(str)
+    job_started    = Signal(str)
+    finished       = Signal(str)
+    error          = Signal(str)
+    raw_data_ready = Signal(str)   # absolute path to temp JSON file
+
+    def __init__(
+        self,
+        seq_file_path: str | None,
+        seq_info: dict | None,
+        orchestrator_url: str,
+        scenario_id: str = "full_pipeline",
+        protocol: str = "",
+        parent=None,
+    ) -> None:
+        super().__init__(parent)
+        self._seq_file    = seq_file_path
+        self._seq_info    = dict(seq_info) if seq_info else {}
+        self._scenario_id = scenario_id
+        self._protocol    = protocol
+        from src.clients.orchestrator_client import OrchestratorClient
+        self._orch = OrchestratorClient(orchestrator_url)
+
+    def run(self) -> None:
+        try:
+            self._run_pipeline()
+        except Exception as exc:
+            if not self.isInterruptionRequested():
+                self.error.emit(str(exc))
+
+    def _run_pipeline(self) -> None:
+        self.progress.emit("Проверка оркестратора...")
+        if not self._orch.healthcheck():
+            raise RuntimeError("Оркестратор недоступен — проверьте, что сервис запущен")
+
+        self.progress.emit(
+            f"Отправка задания в оркестратор "
+            f"[сценарий: {self._scenario_id}, ИП: {self._protocol or '—'}]…"
+        )
+        job_id = self._orch.scan(
+            seq_file_path=self._seq_file or None,
+            seq_info=self._seq_info or None,
+            scenario_id=self._scenario_id,
+            protocol=self._protocol,
+        )
+        self.job_started.emit(job_id)
+        self.progress.emit(f"  Job {job_id[:8]}… запущен, ожидание результата…")
+
+        def _on_status(status: str) -> None:
+            self.progress.emit(f"  [{status}]")
+
+        final = self._orch.poll_scan(
+            job_id,
+            timeout=300.0,
+            poll_interval=2.0,
+            progress_cb=_on_status,
+            interrupted_fn=self.isInterruptionRequested,
+        )
+
+        steps    = final.get("steps", [])
+        meas_id  = None
+        raw_data = None
+        for step in steps:
+            name = step.get("name")
+            res  = step.get("result") or {}
+            if name == "start_measurement":
+                meas_id = res.get("measurement_id")
+            if name == "fetch_data":
+                raw_data = res.get("data")
+
+        self.progress.emit(f"  Сканирование завершено (meas_id={meas_id})")
+
+        is_stub = str(meas_id) in ("", "None", "meas_stub") or meas_id is None
+        if raw_data and not is_stub:
+            raw_path = self._save_raw(raw_data, meas_id)
+            if raw_path:
+                self.raw_data_ready.emit(raw_path)
+
+        self.finished.emit(f"job завершён (meas_id={meas_id})")
+
+    def _save_raw(self, data, meas_id) -> str | None:
+        import tempfile, time as _t
+        fname = f"scan_raw_{meas_id}_{int(_t.time())}.json"
+        fpath = os.path.join(tempfile.gettempdir(), fname)
+        try:
+            with open(fpath, "w", encoding="utf-8") as fh:
+                json.dump(data, fh)
+            self.progress.emit(f"  Данные сохранены: {fname}")
+            return fpath
+        except Exception as exc:
+            self.progress.emit(f"  Не удалось сохранить данные: {exc}")
+            return None
+
+
+# ==============================================================================
 class _ScanWorker(QThread):
     """Fire-and-forget: load scenario and run_all via orchestrator REST."""
 
@@ -432,18 +540,21 @@ class ScanningTab(QWidget):
     """
 
     scan_job_started = Signal(str)   # job_id — emitted once the orchestrator accepts the job
+    raw_data_ready   = Signal(str)   # absolute path to temp JSON file
 
     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]
+        self._viewers:          list[MriViewerWidget]          = []
+        self._scan_tick:        int                            = 0
+        self._seq_info:         dict | None                    = None
+        self._seq_file_path:    str | None                     = None
+        self._orchestrator_url: str                            = "http://localhost:1717"
+        self._scan_worker:      _ScanPipelineWorker | None     = None
+        self._active_protocol:  str                            = _PROTOCOLS[0]
+        self._scenario_id:      str                            = "full_pipeline"
+        self._slice_offset:     list[float]                    = [0.0, 0.0, 0.0]
 
         root = QVBoxLayout(self)
         root.setContentsMargins(0, 0, 0, 0)
@@ -465,9 +576,14 @@ class ScanningTab(QWidget):
     # -- public API ---------------------------------------------------------
 
     def apply_seq_info(self, info_dict: dict) -> None:
-        """Receive exported sequence info from SeqInterpTab."""
+        """Receive exported sequence info from SeqInterpTab and auto-start scan."""
         self._seq_info = dict(info_dict)
+        self._seq_file_path = None
         self._update_scan_ready_state()
+        label = info_dict.get("infostr") or "sequence"
+        self._log(f"Получены параметры последовательности: {label}", "INFO")
+        if not self._btn_scan.isChecked():
+            self._btn_scan.setChecked(True)
 
     def set_orchestrator_url(self, url: str) -> None:
         self._orchestrator_url = url
@@ -507,9 +623,99 @@ class ScanningTab(QWidget):
         return container
 
     def _build_protocol_panel(self) -> QWidget:
+        container = QWidget()
+        container.setStyleSheet(f"background: {_PANEL_BG};")
+        lay = QVBoxLayout(container)
+        lay.setContentsMargins(0, 0, 0, 0)
+        lay.setSpacing(0)
+
         self._protocol_list = ProtocolListWidget()
         self._protocol_list.protocol_selected.connect(self._on_protocol_selected)
-        return self._protocol_list
+        lay.addWidget(self._protocol_list, stretch=1)
+
+        sep = QFrame()
+        sep.setFrameShape(QFrame.HLine)
+        sep.setFixedHeight(1)
+        sep.setStyleSheet("background: #3a3a4a; border: none;")
+        lay.addWidget(sep)
+
+        # Scenario selector
+        sc_container = QWidget()
+        sc_container.setStyleSheet(f"background: {_PANEL_BG};")
+        sc_lay = QVBoxLayout(sc_container)
+        sc_lay.setContentsMargins(6, 6, 6, 4)
+        sc_lay.setSpacing(4)
+
+        sc_header = QLabel("Сценарий оркестратора")
+        sc_header.setStyleSheet(
+            "color: #7777aa; font-size: 10px; font-weight: bold; background: transparent;"
+        )
+        sc_lay.addWidget(sc_header)
+
+        sc_row = QHBoxLayout()
+        sc_row.setSpacing(4)
+
+        _combo_style = (
+            "QComboBox {"
+            f"  background: {_BTN_BG}; color: #ccccee;"
+            "  border: 1px solid #444466; border-radius: 3px;"
+            "  font-size: 11px; padding: 3px 6px;"
+            "}"
+            "QComboBox::drop-down { border: none; width: 16px; }"
+            "QComboBox QAbstractItemView {"
+            f"  background: {_BTN_BG}; color: #ccccee; border: 1px solid #444466;"
+            "  selection-background-color: #e65100;"
+            "}"
+        )
+        self._scenario_combo = QComboBox()
+        self._scenario_combo.setStyleSheet(_combo_style)
+        self._scenario_combo.setToolTip("Тип сценария, который будет запущен в оркестраторе")
+        self._scenario_combo.addItem("full_pipeline")
+        self._scenario_combo.currentTextChanged.connect(self._on_scenario_selected)
+        sc_row.addWidget(self._scenario_combo, stretch=1)
+
+        btn_refresh_sc = QPushButton("↻")
+        btn_refresh_sc.setFixedSize(24, 24)
+        btn_refresh_sc.setToolTip("Получить список сценариев из оркестратора")
+        btn_refresh_sc.setStyleSheet(
+            f"QPushButton {{ background: {_BTN_BG}; color: #7777aa;"
+            "  border: 1px solid #444466; border-radius: 3px; font-size: 13px; }}"
+            "QPushButton:hover { color: #ffffff; background: #303050; }"
+        )
+        btn_refresh_sc.clicked.connect(self._on_refresh_scenarios)
+        sc_row.addWidget(btn_refresh_sc)
+        sc_lay.addLayout(sc_row)
+        lay.addWidget(sc_container)
+
+        sep2 = QFrame()
+        sep2.setFrameShape(QFrame.HLine)
+        sep2.setFixedHeight(1)
+        sep2.setStyleSheet("background: #3a3a4a; border: none;")
+        lay.addWidget(sep2)
+
+        # .seq file loader
+        btn_load = QPushButton("Загрузить .seq…")
+        btn_load.setToolTip("Выбрать готовый .seq файл для запуска полного пайплайна")
+        btn_load.setStyleSheet(
+            "QPushButton {"
+            f"  background: {_BTN_BG}; color: #aaaacc;"
+            "  border: 1px solid #444466; border-radius: 3px;"
+            "  font-size: 11px; padding: 5px 8px; margin: 6px 6px 2px 6px;"
+            "}"
+            "QPushButton:hover { background: #303050; color: #ffffff; }"
+        )
+        btn_load.clicked.connect(self._on_load_seq_clicked)
+        lay.addWidget(btn_load)
+
+        self._lbl_seq_file = QLabel("Файл не выбран")
+        self._lbl_seq_file.setWordWrap(True)
+        self._lbl_seq_file.setStyleSheet(
+            "color: #555577; font-size: 10px; background: transparent;"
+            "padding: 0 8px 6px 8px;"
+        )
+        lay.addWidget(self._lbl_seq_file)
+
+        return container
 
     def _build_image_grid(self) -> QWidget:
         container = QWidget()
@@ -557,6 +763,10 @@ class ScanningTab(QWidget):
 
         geo_tab = self._build_geometry_tab()
         self._param_tabs.addTab(geo_tab, i18n.tr("tab_geometry"))
+
+        log_tab = self._build_log_tab()
+        self._param_tabs.addTab(log_tab, "Лог")
+
         self._param_tabs.setCurrentWidget(geo_tab)
 
         outer.addWidget(self._param_tabs, stretch=1)
@@ -673,6 +883,55 @@ class ScanningTab(QWidget):
         lay.addStretch()
         return w
 
+    def _build_log_tab(self) -> QWidget:
+        w = QWidget()
+        w.setStyleSheet("background: #16162a;")
+        lay = QVBoxLayout(w)
+        lay.setContentsMargins(6, 6, 6, 4)
+        lay.setSpacing(4)
+
+        self._log_view = QTextEdit()
+        self._log_view.setReadOnly(True)
+        self._log_view.setFont(QFont("Courier New", 9))
+        self._log_view.setStyleSheet(
+            "QTextEdit {"
+            "  background: #0e0e1c; color: #aaaacc;"
+            "  border: 1px solid #2a2a4a; border-radius: 3px;"
+            "}"
+        )
+        lay.addWidget(self._log_view, stretch=1)
+
+        btn_clear = QPushButton("Очистить")
+        btn_clear.setFixedWidth(90)
+        btn_clear.setStyleSheet(
+            "QPushButton {"
+            f"  background: {_BTN_BG}; color: #777799;"
+            "  border: 1px solid #333355; border-radius: 3px;"
+            "  font-size: 10px; padding: 3px 8px;"
+            "}"
+            "QPushButton:hover { color: #aaaacc; }"
+        )
+        btn_clear.clicked.connect(self._log_view.clear)
+        lay.addWidget(btn_clear, alignment=Qt.AlignRight)
+        return w
+
+    _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
+
+    def _log(self, msg: str, level: str = "INFO") -> None:
+        if not hasattr(self, "_log_view"):
+            return
+        from datetime import datetime
+        ts    = datetime.now().strftime("%H:%M:%S")
+        color = self._LOG_COLORS.get(level, "#aaaacc")
+        line  = (
+            f"<span style='color:#555577'>{ts}</span>"
+            f"  <span style='color:{color}'>[{level}]</span>"
+            f"  {msg}"
+        )
+        self._log_view.append(line)
+        sb = self._log_view.verticalScrollBar()
+        sb.setValue(sb.maximum())
+
     @staticmethod
     def _group_style() -> str:
         return (
@@ -786,57 +1045,151 @@ class ScanningTab(QWidget):
             btn.setChecked(name == matched)
             btn.blockSignals(False)
 
+    # -- .seq file loading --------------------------------------------------
+
+    def _on_load_seq_clicked(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self,
+            "Выбрать .seq файл",
+            os.path.join(os.path.dirname(__file__), os.pardir, os.pardir),
+            "Pulseq файлы (*.seq);;Все файлы (*)",
+        )
+        if not path:
+            return
+        self._seq_file_path = path
+        self._seq_info = None
+        fname = os.path.basename(path)
+        self._lbl_seq_file.setText(fname)
+        self._lbl_seq_file.setStyleSheet(
+            "color: #e65100; font-size: 10px; background: transparent;"
+            "padding: 0 8px 6px 8px;"
+        )
+        self._log(f"Загружен файл: {fname}", "INFO")
+        self._update_scan_ready_state()
+
     # -- scan initiation ----------------------------------------------------
 
     def _on_scan_toggled(self, checked: bool) -> None:
         if checked:
-            if self._seq_info is None:
+            if self._scan_worker is not None and self._scan_worker.isRunning():
+                return
+            if self._seq_info is None and self._seq_file_path is None:
                 QMessageBox.warning(
                     self, i18n.tr("dlg_no_data_title"),
                     i18n.tr("dlg_no_seq_msg"),
                 )
                 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)
+            info = dict(self._seq_info) if self._seq_info else {}
+            if info:
+                info["rotation_matrix"] = self._compute_rotation_matrix()
+                info["slice_position"]  = list(self._slice_offset)
+            self._scan_worker = _ScanPipelineWorker(
+                seq_file_path=self._seq_file_path,
+                seq_info=info if info else None,
+                orchestrator_url=self._orchestrator_url,
+                scenario_id=self._scenario_id,
+                protocol=self._active_protocol,
+                parent=self,
+            )
+            self._scan_worker.job_started.connect(self.scan_job_started)
             self._scan_worker.finished.connect(self._on_scan_done)
             self._scan_worker.error.connect(self._on_scan_error)
+            self._scan_worker.progress.connect(self._on_scan_progress)
+            self._scan_worker.raw_data_ready.connect(self._on_raw_data_ready_log)
+            self._scan_worker.raw_data_ready.connect(self.raw_data_ready)
             self._scan_worker.start()
             self._scan_timer.start()
             self._btn_scan.setText(i18n.tr("btn_stop_scan"))
-            self._status_label.setText(i18n.tr("scanning_status"))
+            self._status_label.setText("Инициализация пайплайна…")
             self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
+            self._log("--- Запуск пайплайна сканирования ---", "INFO")
             for v in self._viewers:
                 v.set_scanning(True)
+            # Switch to Log tab so the user can follow progress
+            log_idx = self._param_tabs.indexOf(self._log_view.parent())
+            if log_idx >= 0:
+                self._param_tabs.setCurrentIndex(log_idx)
         else:
             self._scan_timer.stop()
+            if self._scan_worker and self._scan_worker.isRunning():
+                self._scan_worker.requestInterruption()
             self._btn_scan.setText(i18n.tr("btn_scan"))
             for v in self._viewers:
                 v.set_scanning(False)
             self._update_scan_ready_state()
 
-    def _on_scan_done(self, job_id: str) -> None:
-        short = job_id[:8] + "..." if len(job_id) > 8 else job_id
-        self._status_label.setText(f"{i18n.tr('done_status')} ({short})")
+    def _on_raw_data_ready_log(self, path: str) -> None:
+        self._log(f"Данные отправлены во вкладку Spectroscopy: {os.path.basename(path)}", "INFO")
+
+    def _on_scan_progress(self, msg: str) -> None:
+        self._status_label.setText(msg[:80])
+        self._status_label.setStyleSheet("color: #88ee88; font-size: 11px;")
+        self._log(msg, "INFO")
+
+    def _on_scan_done(self, msg: str) -> None:
+        self._scan_timer.stop()
+        self._status_label.setText("Готово")
         self._status_label.setStyleSheet("color: #66ccff; font-size: 11px;")
+        self._log(msg, "INFO")
+        for v in self._viewers:
+            v.set_scanning(False)
         self._btn_scan.setChecked(False)
-        self.scan_job_started.emit(job_id)
 
     def _on_scan_error(self, err: str) -> None:
-        self._status_label.setText(f"{i18n.tr('error_prefix')}: {err[:60]}")
+        self._scan_timer.stop()
+        self._status_label.setText(f"{i18n.tr('error_prefix')}: {err[:70]}")
         self._status_label.setStyleSheet("color: #ee4444; font-size: 11px;")
+        self._log(err, "ERR")
+        for v in self._viewers:
+            v.set_scanning(False)
         self._btn_scan.setChecked(False)
 
     def _update_scan_ready_state(self) -> None:
-        if self._seq_info is not None:
+        if self._seq_file_path:
+            fname = os.path.basename(self._seq_file_path)
+            self._status_label.setText(f"Файл: {fname}")
+            self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
+        elif self._seq_info is not None:
             self._status_label.setText(i18n.tr("ready_to_scan"))
             self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
         else:
             self._status_label.setText(i18n.tr("no_data"))
             self._status_label.setStyleSheet("color: #666688; font-size: 11px;")
 
+    # -- scenario selection -------------------------------------------------
+
+    def _on_scenario_selected(self, name: str) -> None:
+        if name:
+            self._scenario_id = name
+
+    def _on_refresh_scenarios(self) -> None:
+        import httpx
+        try:
+            r = httpx.get(
+                f"{self._orchestrator_url}/scenario/list",
+                timeout=5.0,
+            )
+            if r.is_success:
+                scenarios: list[str] = r.json().get("scenarios", [])
+                if scenarios:
+                    current = self._scenario_combo.currentText()
+                    self._scenario_combo.blockSignals(True)
+                    self._scenario_combo.clear()
+                    for s in scenarios:
+                        self._scenario_combo.addItem(s)
+                    idx = self._scenario_combo.findText(current)
+                    self._scenario_combo.setCurrentIndex(max(idx, 0))
+                    self._scenario_combo.blockSignals(False)
+                    self._scenario_id = self._scenario_combo.currentText()
+                    self._log(f"Сценарии загружены: {scenarios}", "INFO")
+                else:
+                    self._log("Оркестратор вернул пустой список сценариев", "WARN")
+            else:
+                self._log(f"Ошибка загрузки сценариев: HTTP {r.status_code}", "WARN")
+        except Exception as exc:
+            self._log(f"Не удалось подключиться к оркестратору: {exc}", "WARN")
+
     # -- protocol selection -------------------------------------------------
 
     def _on_protocol_selected(self, name: str) -> None:

+ 3 - 0
docker-compose.yml

@@ -14,6 +14,9 @@ services:
       SPECTROMETER_URL: http://spectrometer:8000
       RECONSTRUCTOR_URL: http://reconstructor:8000
       SEQ_INTERP_URL: http://seq-interp:7475
+      STUB_DATA_PATH: /app/stub_data/response.json
+    volumes:
+      - "${STUB_DATA_FILE:-C:/Users/user.LAPTOP-M7DTCFMM/Downloads/Telegram Desktop/response.json}:/app/stub_data/response.json:ro"
     restart: unless-stopped
     healthcheck:
       test: ["CMD", "curl", "-f", "http://localhost:1717/health"]

+ 31 - 1
services/orchestrator/orchestrator/tasks_plug.py

@@ -1,21 +1,51 @@
+import json
+import os
 import time
 
+# Path where response.json is mounted inside the container
+_STUB_DATA_PATH = os.getenv("STUB_DATA_PATH", "/app/stub_data/response.json")
+
+_stub_measurements: list | None = None
+
+
+def _load_stub_data() -> list:
+    global _stub_measurements
+    if _stub_measurements is None:
+        if os.path.exists(_STUB_DATA_PATH):
+            with open(_STUB_DATA_PATH, encoding="utf-8") as fh:
+                _stub_measurements = json.load(fh)
+        else:
+            _stub_measurements = []
+    return _stub_measurements
+
+
 def start_measurement(**kwargs):
     time.sleep(1)
-    return {"measurement_id": "meas_stub", "status": "ok"}
+    measurements = _load_stub_data()
+    meas_id = measurements[0]["measurement_id"] if measurements else "meas_stub"
+    return {"measurement_id": meas_id, "status": "ok"}
+
 
 def wait_data_ready(**kwargs):
     time.sleep(1)
     return {"data_ready": True}
 
+
 def fetch_data(format="h5", **kwargs):
     time.sleep(1)
+    measurements = _load_stub_data()
+    if measurements:
+        # Wrap in a list so the spectroscopy service and GUI range-detector
+        # can find averaging_num / data_num / channel_num correctly
+        return {"data": measurements[: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,

+ 1 - 0
services/orchestrator/requirements.txt

@@ -1,5 +1,6 @@
 fastapi==0.115.0
 uvicorn[standard]==0.30.6
+python-multipart==0.0.9
 pyyaml==6.0.2
 requests==2.32.3
 numpy>=1.26