|
|
@@ -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:
|