Преглед на файлове

refactoring of GUI-orchestration connections

spacexerq преди 3 седмици
родител
ревизия
5c61c90655

+ 61 - 9
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 PySide6.QtWidgets import (
     QApplication,
     QButtonGroup,
@@ -30,7 +30,8 @@ from src.tabs.scanning_tab import ScanningTab
 from src.tabs.seq_interp_tab import SeqInterpTab
 from src.tabs.spectroscopy_tab import SpectroscopyTab
 
-_TAB_NAMES = ["Sequence", "Scanner", "FID", "Scanning", "Spectroscopy"]
+_TAB_NAMES = ["Scanning", "Sequence", "Scanner", "Spectroscopy", "FID"]
+_SPEC_TAB_IDX = 3  # index of the Spectroscopy tab
 
 _NAV_BG = "#0f0f1e"
 _NAV_H = 38
@@ -63,6 +64,27 @@ QToolBar {{
 }}
 """
 
+# Stylesheet applied to the Spectroscopy nav button while data-arrival blink is ON
+_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."""
@@ -91,6 +113,8 @@ class LFMRIWindow(QMainWindow):
         self._scanner_tab = ScannerTab(
             hw_config_path=hw_config_path,
             orchestrator_url=orchestrator_url,
+            seq_interp_url=seq_interp_url,
+            spectroscopy_url=spectroscopy_url,
         )
         self._fid_tab = FidTab(
             hw_config_path=hw_config_path,
@@ -103,16 +127,24 @@ class LFMRIWindow(QMainWindow):
         self._tabs = QTabWidget()
         self._tabs.tabBar().hide()
         self._tabs.setDocumentMode(True)
-        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.addTab(self._spectroscopy_tab, _TAB_NAMES[4])
+        self._tabs.addTab(self._scanning_tab, _TAB_NAMES[0])
+        self._tabs.addTab(self._seq_tab, _TAB_NAMES[1])
+        self._tabs.addTab(self._scanner_tab, _TAB_NAMES[2])
+        self._tabs.addTab(self._spectroscopy_tab, _TAB_NAMES[3])
+        self._tabs.addTab(self._fid_tab, _TAB_NAMES[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.raw_data_ready.connect(self._on_scan_raw_data_ready)
+        self._scanning_tab.scan_job_started.connect(self._scanner_tab.attach_job)
+
+        # -- blink timer (off by default) ----------------------------------
+        self._spec_blink_on: bool = False
+        self._spec_blink_timer = QTimer(self)
+        self._spec_blink_timer.setInterval(700)   # slow orange blink: 700 ms
+        self._spec_blink_timer.timeout.connect(self._tick_spec_blink)
 
         self.menuBar().hide()
         self._build_nav_bar()
@@ -194,15 +226,35 @@ class LFMRIWindow(QMainWindow):
         self.statusBar().showMessage(f"Active: {name}")
         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_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:
+        """Raw measurement data arrived — push to spectroscopy tab and start blink."""
+        self._spectroscopy_tab.receive_scan_data(json_path)
+        if self._tabs.currentIndex() != _SPEC_TAB_IDX:
+            self._spec_blink_on = False
+            self._spec_blink_timer.start()
+
+    # -- nav button blink (Spectroscopy tab) --------------------------------
+
+    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):

+ 105 - 2
apps/gui/src/clients/orchestrator_client.py

@@ -55,9 +55,17 @@ class OrchestratorClient:
     # -- public API ---------------------------------------------------------
 
     def healthcheck(self) -> bool:
-        """Return True if the orchestrator responds on /scenario/list."""
+        """Return True if the orchestrator responds on /health."""
         try:
-            self._get("/scenario/list")
+            self._get("/health")
+            return True
+        except OrchestratorError:
+            return False
+
+    def spectrometer_healthcheck(self) -> bool:
+        """Return True if the spectrometer service is reachable (via orchestrator proxy)."""
+        try:
+            self._get("/spectrometer/health")
             return True
         except OrchestratorError:
             return False
@@ -93,3 +101,98 @@ class OrchestratorClient:
     def get_status(self, job_id: str) -> dict:
         """Return the current scenario state (steps + their statuses)."""
         return self._get(f"/scenario/{job_id}")
+
+    def scan(
+        self,
+        seq_file_path: str | None = None,
+        seq_info: dict | None = None,
+        scenario_id: str = "full_pipeline",
+        protocol: str = "",
+    ) -> str:
+        """
+        Submit a scan job to the orchestrator and return the job_id immediately.
+
+        Provide either:
+          - seq_file_path: path to a .seq file → orchestrator interprets it via seq-interp
+          - seq_info: already-interpreted parameters dict
+
+        Use poll_scan(job_id) to wait for completion.
+        """
+        import json as _json
+        import httpx
+
+        data: dict[str, str] = {
+            "scenario_id": scenario_id,
+            "protocol": protocol,
+        }
+        files = None
+
+        if seq_file_path:
+            import os as _os
+            with open(seq_file_path, "rb") as fh:
+                file_bytes = fh.read()
+            files = {
+                "file": (_os.path.basename(seq_file_path), file_bytes, "application/octet-stream")
+            }
+        elif seq_info:
+            data["seq_info_json"] = _json.dumps(seq_info)
+
+        try:
+            r = httpx.post(
+                f"{self.base_url}/scan/",
+                data=data,
+                files=files,
+                timeout=httpx.Timeout(connect=5.0, read=60.0, write=60.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()["job_id"]
+
+    def poll_scan(
+        self,
+        job_id: str,
+        timeout: float = 300.0,
+        poll_interval: float = 2.0,
+        progress_cb=None,
+        interrupted_fn=None,
+    ) -> dict:
+        """
+        Block until the scan job finishes (status == 'done' or starts with 'failed').
+        Returns the final scenario dict from GET /scenario/{job_id}.
+        Raises OrchestratorError on timeout or failure.
+
+        interrupted_fn: optional callable() -> bool; if it returns True the loop
+        exits immediately with OrchestratorError("cancelled").
+        """
+        import time
+        deadline = time.monotonic() + timeout
+        last_status = ""
+        step = 0.25  # sleep granularity for responsive cancellation
+
+        while time.monotonic() < deadline:
+            # Sleep in small steps so a cancellation request is noticed quickly
+            elapsed = 0.0
+            while elapsed < poll_interval:
+                if interrupted_fn and interrupted_fn():
+                    raise OrchestratorError("cancelled")
+                time.sleep(step)
+                elapsed += step
+            doc = self.get_status(job_id)
+            status = doc.get("status", "")
+
+            if status != last_status:
+                last_status = status
+                if progress_cb:
+                    progress_cb(status)
+
+            if status == "done":
+                return doc
+            if status.startswith("failed"):
+                raise OrchestratorError(f"Scan job failed: {status}")
+
+        raise OrchestratorError(f"Scan job timed out after {timeout}s (job_id={job_id})")

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

@@ -30,6 +30,20 @@ _STATUS_COLORS = {
 
 _POLL_INTERVAL_MS = 1500
 
+# Service status bar
+_SVC_POLL_INTERVAL_MS = 5_000
+
+_DOT_ONLINE   = "● "
+_DOT_OFFLINE  = "● "
+_DOT_UNKNOWN  = "● "
+
+_SVC_STYLE = {
+    "online":   "color: #2e7d32; font-weight: bold; font-size: 12px;",
+    "offline":  "color: #c62828; font-weight: bold; font-size: 12px;",
+    "unknown":  "color: #757575; font-weight: bold; font-size: 12px;",
+    "checking": "color: #e65100; font-weight: bold; font-size: 12px;",
+}
+
 
 class ScannerTab(QWidget):
     """Orchestrator-based scanner control panel."""
@@ -38,11 +52,21 @@ class ScannerTab(QWidget):
         self,
         hw_config_path: str | None = None,
         orchestrator_url: str = "http://localhost:1717",
+        seq_interp_url: str = "http://localhost:7475",
+        reconstructor_url: str = "http://localhost:8081",
+        spectroscopy_url: str = "http://localhost:8002",
+        spectrometer_url: str = "http://localhost:8000",
         parent: QWidget | None = None,
     ) -> None:
         super().__init__(parent)
 
         self._hw_config_path = hw_config_path
+        self._orchestrator_url = orchestrator_url.rstrip("/")
+        self._seq_interp_url   = seq_interp_url.rstrip("/")
+        self._reconstructor_url = reconstructor_url.rstrip("/")
+        self._spectroscopy_url  = spectroscopy_url.rstrip("/")
+        self._spectrometer_url  = spectrometer_url.rstrip("/")
+
         self._client = OrchestratorClient(orchestrator_url)
         self._job_id: str | None = None
         self._seq_info: dict | None = None
@@ -55,8 +79,18 @@ class ScannerTab(QWidget):
         self._poll_timer.setInterval(_POLL_INTERVAL_MS)
         self._poll_timer.timeout.connect(self._poll_status)
 
+        # Service status polling
+        self._svc_workers: dict[str, OrchestratorWorker] = {}
+        self._svc_timer = QTimer(self)
+        self._svc_timer.setInterval(_SVC_POLL_INTERVAL_MS)
+        self._svc_timer.timeout.connect(self._poll_all_services)
+
         self._build_layout()
 
+        # Kick off initial check after the event loop starts
+        QTimer.singleShot(300, self._poll_all_services)
+        self._svc_timer.start()
+
     # ================================================================== #
     #  Public API                                                          #
     # ================================================================== #
@@ -65,6 +99,25 @@ class ScannerTab(QWidget):
         self._hw_config_path = path
         self._append_log(f"HW config: {path}")
 
+    def attach_job(self, job_id: str) -> None:
+        """
+        Called when ScanningTab starts a new scan job via the orchestrator.
+        Wires the job into the Scanner tab so the operator can monitor progress.
+        """
+        self._job_id = job_id
+        short = job_id[:24] + "..." if len(job_id) > 24 else job_id
+        self._job_label.setText(short)
+        self._append_log(f"Scan job received from Scanning tab: {job_id}")
+
+        self._steps_table.setRowCount(0)
+        self._btn_run_all.setEnabled(False)
+        self._btn_next.setEnabled(False)
+        self._btn_abort.setEnabled(True)
+
+        # Start polling so the steps table updates in real time
+        self._fetch_status_once()
+        self._poll_timer.start()
+
     def apply_seq_info(self, info_dict: dict) -> None:
         """Receive sequence info from SeqInterpTab after export."""
         self._seq_info = info_dict
@@ -88,6 +141,7 @@ class ScannerTab(QWidget):
         root.setContentsMargins(6, 6, 6, 6)
         root.setSpacing(6)
 
+        root.addWidget(self._build_services_bar())
         root.addWidget(self._build_connection_bar())
 
         split = QSplitter(Qt.Horizontal)
@@ -96,6 +150,112 @@ class ScannerTab(QWidget):
         split.setSizes([280, 720])
         root.addWidget(split, stretch=1)
 
+    # ================================================================== #
+    #  Services status bar                                                #
+    # ================================================================== #
+
+    def _build_services_bar(self) -> QWidget:
+        """Horizontal strip showing live status of every microservice."""
+        bar = QWidget()
+        bar.setStyleSheet(
+            "QWidget { background: #1a1a2e; border-radius: 4px; }"
+        )
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(8, 4, 8, 4)
+        lay.setSpacing(0)
+
+        title = QLabel("Сервисы:")
+        title.setStyleSheet("color: #555577; font-size: 11px; background: transparent;")
+        lay.addWidget(title)
+        lay.addSpacing(10)
+
+        # (key, display_name, tooltip_url)
+        self._svc_defs: list[tuple[str, str, str]] = [
+            ("orchestrator",  "Orchestrator",  self._orchestrator_url),
+            ("seq_interp",    "Seq-Interp",    self._seq_interp_url),
+            ("spectrometer",  "Spectrometer",  self._spectrometer_url),
+            ("reconstructor", "Reconstructor", self._reconstructor_url),
+            ("spectroscopy",  "Spectroscopy",  self._spectroscopy_url),
+        ]
+
+        self._svc_labels: dict[str, QLabel] = {}
+        for i, (key, name, url) in enumerate(self._svc_defs):
+            lbl = QLabel(f"{_DOT_UNKNOWN}{name}")
+            lbl.setStyleSheet(_SVC_STYLE["unknown"])
+            lbl.setToolTip(url)
+            self._svc_labels[key] = lbl
+            lay.addWidget(lbl)
+            if i < len(self._svc_defs) - 1:
+                sep = QLabel("  |  ")
+                sep.setStyleSheet("color: #333355; background: transparent;")
+                lay.addWidget(sep)
+
+        lay.addStretch()
+
+        btn_refresh = QPushButton("↻")
+        btn_refresh.setFixedSize(22, 22)
+        btn_refresh.setToolTip("Проверить статусы сейчас")
+        btn_refresh.setStyleSheet(
+            "QPushButton { background: #252540; color: #7777aa;"
+            "  border: 1px solid #333355; border-radius: 3px; font-size: 13px; }"
+            "QPushButton:hover { color: #ffffff; background: #303060; }"
+        )
+        btn_refresh.clicked.connect(self._poll_all_services)
+        lay.addWidget(btn_refresh)
+
+        return bar
+
+    def _poll_all_services(self) -> None:
+        """Kick off background health checks for every service."""
+        import httpx
+
+        def _check(url: str) -> bool:
+            try:
+                r = httpx.get(url, timeout=3.0)
+                return r.status_code < 500
+            except Exception:
+                return False
+
+        checks: dict[str, str] = {
+            "orchestrator":  f"{self._orchestrator_url}/health",
+            "seq_interp":    f"{self._seq_interp_url}/health",
+            # Spectrometer accessed via orchestrator proxy to avoid CORS / auth issues
+            "spectrometer":  f"{self._orchestrator_url}/spectrometer/health",
+            "reconstructor": f"{self._reconstructor_url}/health",
+            "spectroscopy":  f"{self._spectroscopy_url}/health",
+        }
+
+        for key, url in checks.items():
+            # Skip if a previous check for this service is still running
+            existing = self._svc_workers.get(key)
+            if existing and existing.isRunning():
+                continue
+            # Do NOT reset the indicator here — keep the last known state
+            # until the new result arrives to avoid flickering.
+            worker = OrchestratorWorker(_check, url)
+            worker.finished.connect(
+                lambda ok, k=key: self._on_svc_checked(k, bool(ok))
+            )
+            worker.error.connect(
+                lambda _msg, k=key: self._on_svc_checked(k, False)
+            )
+            worker.start()
+            self._svc_workers[key] = worker
+
+    def _on_svc_checked(self, key: str, online: bool) -> None:
+        self._set_svc_state(key, "online" if online else "offline")
+
+    def _set_svc_state(self, key: str, state: str) -> None:
+        lbl = self._svc_labels.get(key)
+        if lbl is None:
+            return
+        dot = _DOT_ONLINE if state == "online" else (
+              _DOT_OFFLINE if state == "offline" else _DOT_UNKNOWN)
+        # keep the display name (text after the dot)
+        name = lbl.text()[2:]   # strip old "● "
+        lbl.setText(f"{dot}{name}")
+        lbl.setStyleSheet(_SVC_STYLE.get(state, _SVC_STYLE["unknown"]))
+
     def _build_connection_bar(self) -> QWidget:
         bar = QWidget()
         lay = QHBoxLayout(bar)
@@ -404,6 +564,13 @@ class ScannerTab(QWidget):
         steps = status.get("steps", [])
         self._update_steps_table(steps)
 
+        # Stop polling when the orchestrator marks the job as done or failed
+        job_status = status.get("status", "")
+        if job_status == "done" or job_status.startswith("failed"):
+            self._poll_timer.stop()
+            self._btn_abort.setEnabled(False)
+            self._append_log(f"Job finished: {job_status}")
+
     # ================================================================== #
     #  Steps table                                                         #
     # ================================================================== #

+ 384 - 76
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,17 +22,11 @@ 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:
-    import httpx as _httpx
-    _HAS_HTTPX = True
-except ImportError:
-    _HAS_HTTPX = False
-
 # -- colour palette -------------------------------------------------------------
 _BG_DARK      = "#1a1a2e"
 _PANEL_BG     = "#2a2a2a"
@@ -358,61 +353,119 @@ class ProtocolListWidget(QWidget):
 
 
 # ==============================================================================
-class _ScanWorker(QThread):
-    """Fire-and-forget: load scenario and run_all via orchestrator REST."""
+class _ScanPipelineWorker(QThread):
+    """
+    Thin worker: sends everything to the orchestrator via POST /scan/ and
+    polls GET /scenario/{job_id} until done.
 
-    finished = Signal(str)   # job_id or success message
-    error    = Signal(str)
+    The orchestrator is responsible for:
+      - Forwarding the .seq file to seq-interp for interpretation
+      - Running the full measurement scenario (spectrometer, reconstructor, …)
 
-    def __init__(self, url: str, info: dict, parent=None) -> None:
+    The GUI never calls seq-interp or any other microservice directly.
+    """
+
+    progress       = Signal(str)
+    job_started    = Signal(str)   # job_id received from orchestrator
+    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._url  = url.rstrip("/")
-        self._info = info
+        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)
+
+    # -- main run -----------------------------------------------------------
 
     def run(self) -> None:
         try:
-            if not _HAS_HTTPX:
-                import urllib.request, urllib.error
-                self._run_urllib()
-            else:
-                self._run_httpx()
+            self._run_pipeline()
         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",
+            if not self.isInterruptionRequested():
+                self.error.emit(str(exc))
+
+    def _run_pipeline(self) -> None:
+        # 1. Quick orchestrator reachability check
+        self.progress.emit("Проверка оркестратора...")
+        if not self._orch.healthcheck():
+            raise RuntimeError("Оркестратор недоступен — проверьте, что сервис запущен")
+
+        # 2. Submit the scan job — orchestrator handles interpretation + pipeline
+        self.progress.emit(
+            f"Отправка задания в оркестратор "
+            f"[сценарий: {self._scenario_id}, ИП: {self._protocol or '—'}]…"
         )
-        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",
+        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]}… запущен, ожидание результата…")
+
+        # 3. Poll until done (orchestrator runs everything in background)
+        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,
         )
-        with urllib.request.urlopen(req2, timeout=15):
-            pass
-        self.finished.emit(f"job_id={job_id}")
+
+        # 4. Extract raw measurement data from step results
+        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})")
+
+        # 5. Persist raw data for Spectroscopy tab
+        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})")
+
+    # -- raw data persistence -----------------------------------------------
+
+    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
 
 
 # ==============================================================================
@@ -426,17 +479,22 @@ class ScanningTab(QWidget):
     "Геометрия" holds orientation presets + Rx/Ry/Rz spinboxes + live 3x3 matrix.
     """
 
+    scan_job_started = Signal(str)  # job_id — forwarded to ScannerTab for queue monitoring
+    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)
@@ -458,9 +516,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
@@ -484,9 +547,100 @@ 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 ------------------------------------------------
+        scenario_container = QWidget()
+        scenario_container.setStyleSheet(f"background: {_PANEL_BG};")
+        sc_lay = QVBoxLayout(scenario_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")   # default
+        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(scenario_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()
@@ -534,6 +688,10 @@ class ScanningTab(QWidget):
 
         geo_tab = self._build_geometry_tab()
         self._param_tabs.addTab(geo_tab, "Геометрия")
+
+        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)
@@ -550,7 +708,7 @@ class ScanningTab(QWidget):
         action_lay.addWidget(self._status_label)
         action_lay.addStretch()
 
-        self._btn_scan = QPushButton("Run  Сканировать")
+        self._btn_scan = QPushButton("Сканировать")
         self._btn_scan.setCheckable(True)
         self._btn_scan.setMinimumWidth(140)
         self._btn_scan.setStyleSheet(
@@ -647,6 +805,58 @@ 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
+
+    # level: "INFO" | "WARN" | "ERR"
+    _LOG_COLORS = {"INFO": "#aaaacc", "WARN": "#e6a817", "ERR": "#ee4444"}
+
+    def _log(self, msg: str, level: str = "INFO") -> None:
+        """Append [LEVEL] HH:MM:SS  message to the log and auto-scroll."""
+        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 (
@@ -760,56 +970,154 @@ 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, "Нет данных",
-                    "Сначала загрузите и экспортируйте последовательность\n"
-                    'во вкладке "Sequence".'
+                    "Загрузите .seq файл кнопкой «Загрузить .seq…»\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)
+            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("Stop  Стоп")
-            self._status_label.setText("Сканирование...")
+            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
+            self._param_tabs.setCurrentIndex(
+                self._param_tabs.indexOf(self._log_view.parent())
+            )
         else:
             self._scan_timer.stop()
+            if self._scan_worker and self._scan_worker.isRunning():
+                self._scan_worker.requestInterruption()
             self._btn_scan.setText("Run  Сканировать")
             for v in self._viewers:
                 v.set_scanning(False)
             self._update_scan_ready_state()
 
+    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._status_label.setText(f"Готово ({msg})")
+        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)
 
     def _on_scan_error(self, err: str) -> None:
-        self._status_label.setText(f"Ошибка: {err[:60]}")
+        self._scan_timer.stop()
+        self._status_label.setText(f"Ошибка: {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("Готово к сканированию")
             self._status_label.setStyleSheet("color: #e65100; font-size: 11px;")
         else:
             self._status_label.setText("Нет данных")
             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:
+        """Fetch available scenario IDs from the orchestrator."""
+        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)
+                    # restore previous selection if still available
+                    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:

+ 12 - 0
apps/gui/src/tabs/spectroscopy_tab.py

@@ -130,6 +130,18 @@ class SpectroscopyTab(QWidget):
     def set_spectroscopy_url(self, url: str) -> None:
         self._client = SpectroscopyClient(url)
 
+    def receive_scan_data(self, json_path: str) -> None:
+        """
+        Called automatically after a scan completes: load the raw JSON file
+        and start NMR analysis without user interaction.
+        """
+        if not os.path.isfile(json_path):
+            return
+        self._last_json_path = json_path
+        self._update_data_ranges_from_file(json_path)
+        self._btn_analyze.setEnabled(True)
+        self._run_analysis(json_path)
+
     def current_params(self) -> dict:
         """Return current NMR parameters as a plain dict."""
         return {

+ 0 - 3
docker-compose.yml

@@ -14,9 +14,6 @@ services:
       SPECTROMETER_URL: http://spectrometer:8000
       RECONSTRUCTOR_URL: http://reconstructor:8000
       SEQ_INTERP_URL: http://seq-interp:7475
-    depends_on:
-      seq-interp:
-        condition: service_healthy
     restart: unless-stopped
     healthcheck:
       test: ["CMD", "curl", "-f", "http://localhost:1717/health"]

+ 138 - 1
services/orchestrator/orchestrator/main.py

@@ -1,7 +1,9 @@
 import uuid
+import json
 import os
+import threading
 from typing import Any, Dict, Optional
-from fastapi import FastAPI, HTTPException, Query
+from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
 from pydantic import BaseModel
 from .scenario import Scenario, Step, StepStatus
 from .docstore import JobDoc
@@ -31,6 +33,31 @@ def health():
     return {"status": "ok"}
 
 
+@app.get("/spectrometer/health")
+def spectrometer_health():
+    """Check connectivity to the spectrometer service."""
+    spec_url  = os.getenv("SPECTROMETER_URL",      "http://localhost:8000")
+    spec_user = os.getenv("SPECTROMETER_USER",     "admin")
+    spec_pass = os.getenv("SPECTROMETER_PASSWORD", "admin")
+    try:
+        import requests
+        r = requests.get(
+            f"{spec_url}/api/",
+            auth=(spec_user, spec_pass),
+            timeout=5.0,
+        )
+        if r.status_code < 500:
+            return {"status": "ok", "spectrometer_url": spec_url}
+        raise HTTPException(
+            status_code=503,
+            detail=f"Spectrometer returned HTTP {r.status_code}",
+        )
+    except requests.ConnectionError as exc:
+        raise HTTPException(status_code=503, detail=f"Spectrometer unreachable: {exc}")
+    except requests.Timeout:
+        raise HTTPException(status_code=503, detail="Spectrometer health check timed out")
+
+
 # Память для живых Scenario (по job_id)
 SCENARIOS: Dict[str, Scenario] = {}
 # Кэш шаблонов сценариев
@@ -135,6 +162,116 @@ def get_status(job_id: str):
     return doc.scenario
 
 
+def _build_scenario_for_job(
+    tpl: dict,
+    seq_info: dict,
+    protocol: str,
+) -> Scenario:
+    """
+    Build a Scenario from template, injecting seq_info + protocol into
+    the start_measurement step params.  Raises ValueError for unknown tasks.
+    """
+    steps = []
+    for item in tpl.get("steps", []):
+        name = item["name"]
+        params = dict(item.get("params", {}) or {})
+        if name not in TASK_REGISTRY:
+            raise ValueError(f"Unknown task name in template: {name!r}")
+        if name == "start_measurement":
+            if seq_info:
+                params["info"] = {**params.get("info", {}), **seq_info}
+            if protocol:
+                params["protocol"] = protocol
+        steps.append(Step(name=name, func=TASK_REGISTRY[name], params=params))
+    return Scenario(steps=steps)
+
+
+def _run_scan_background(
+    job_id: str,
+    file_bytes: bytes | None,
+    filename: str | None,
+    seq_info_json: str | None,
+    scenario_id: str,
+    protocol: str,
+) -> None:
+    """
+    Background thread: interpret .seq (if provided) → build scenario → run_all.
+    Saves job state to DocStore at each major step so the GUI can poll it.
+    """
+    def _save(status: str, steps: list | None = None) -> None:
+        payload: dict = {"status": status}
+        if steps is not None:
+            payload["steps"] = steps
+        JobDoc(id=job_id, scenario=payload).save()
+
+    try:
+        # 1. Interpret .seq file via seq-interp (if provided)
+        seq_info: dict = {}
+        if file_bytes and filename:
+            _save("interpreting")
+            seq_interp_url = os.getenv("SEQ_INTERP_URL", "http://seq-interp:7475")
+            from .clients.seq_interp_cl import SeqInterpClient, SeqInterpError
+            client = SeqInterpClient(seq_interp_url)
+            result = client.interpret_and_wait(file_bytes, filename)
+            post_json = result.get("post_json", {})
+            seq_info = post_json.get("info", post_json) or {}
+        elif seq_info_json:
+            seq_info = json.loads(seq_info_json)
+
+        # 2. Build scenario
+        tpl = SCENARIO_TEMPLATES.get(scenario_id)
+        if not tpl:
+            _save(f"failed: unknown scenario '{scenario_id}'")
+            return
+
+        scenario = _build_scenario_for_job(tpl, seq_info, protocol)
+        SCENARIOS[job_id] = scenario
+
+        _save("running", [step_to_dict(s) for s in scenario.steps])
+
+        # 3. Run all steps (blocking); scenario catches per-step exceptions internally
+        scenario.run_all()
+
+        _save("done", [step_to_dict(s) for s in scenario.steps])
+
+    except Exception as exc:
+        JobDoc(id=job_id, scenario={"status": f"failed: {exc}", "steps": []}).save()
+
+
+@app.post("/scan/")
+async def scan_endpoint(
+    file: UploadFile = File(None),
+    seq_info_json: str = Form(None),
+    scenario_id: str = Form("full_pipeline"),
+    protocol: str = Form(""),
+):
+    """
+    Single entry point for the GUI scan button.
+
+    Accepts either:
+      - a .seq file (multipart) → orchestrator forwards to seq-interp for interpretation
+      - seq_info_json (form field, JSON string) → already-interpreted parameters
+
+    Returns {"job_id": "..."} immediately; the pipeline runs in a background thread.
+    Poll GET /scenario/{job_id} for status / step results.
+    """
+    job_id = str(uuid.uuid4())
+    file_bytes = await file.read() if file else None
+    filename = file.filename if (file and file.filename) else None
+
+    # Register job immediately so polling can start right away
+    JobDoc(id=job_id, scenario={"status": "queued", "steps": []}).save()
+
+    threading.Thread(
+        target=_run_scan_background,
+        args=(job_id, file_bytes, filename, seq_info_json, scenario_id, protocol),
+        daemon=True,
+        name=f"scan-{job_id[:8]}",
+    ).start()
+
+    return {"job_id": job_id}
+
+
 @app.get("/measurement/{meas_id}/decode")
 def decode_measurement_endpoint(
     meas_id: int,

+ 2 - 0
services/seq-interp/Dockerfile

@@ -23,6 +23,8 @@ 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
+RUN pip install --no-cache-dir /app/LF_scanner
+
 COPY services/seq-interp /app/seq_interp
 
 WORKDIR /app/seq_interp