ソースを参照

update spectroscopy tab

spacexerq 1 日 前
コミット
650aa1e9dd

+ 3 - 0
apps/gui/app.py

@@ -61,12 +61,14 @@ def main() -> None:
     _cfg_path = os.path.join(_here, "cfg", "server_config.json")
     orchestrator_url = "http://localhost:1717"
     seq_interp_url   = "http://localhost:7475"
+    spectroscopy_url = "http://localhost:8002"
     try:
         import json as _json
         with open(_cfg_path, encoding="utf-8") as _f:
             _cfg = _json.load(_f)
         orchestrator_url = _cfg.get("orchestrator_url", orchestrator_url)
         seq_interp_url   = _cfg.get("seq_interp_url",   seq_interp_url)
+        spectroscopy_url = _cfg.get("spectroscopy_url", spectroscopy_url)
     except Exception:
         pass
 
@@ -76,6 +78,7 @@ def main() -> None:
         seq_file=seq_file,
         orchestrator_url=orchestrator_url,
         seq_interp_url=seq_interp_url,
+        spectroscopy_url=spectroscopy_url,
     )
     win.show()
     sys.exit(app.exec())

+ 2 - 1
apps/gui/cfg/server_config.json

@@ -6,5 +6,6 @@
   "server_host": "0.0.0.0",
   "server_port": 7475,
   "orchestrator_url": "http://localhost:1717",
-  "seq_interp_url": "http://localhost:7475"
+  "seq_interp_url": "http://localhost:7475",
+  "spectroscopy_url": "http://localhost:8002"
 }

+ 19 - 14
apps/gui/src/app_window.py

@@ -21,9 +21,10 @@ from src.tabs.seq_interp_tab import SeqInterpTab
 from src.tabs.scanner_tab import ScannerTab
 from src.tabs.fid_tab import FidTab
 from src.tabs.scanning_tab import ScanningTab
+from src.tabs.spectroscopy_tab import SpectroscopyTab
 from src.server_worker import ServerWorker
 
-_TAB_NAMES = ["Sequence", "Scanner", "FID", "Scanning"]
+_TAB_NAMES = ["Sequence", "Scanner", "FID", "Scanning", "Spectroscopy"]
 
 # ── nav-bar style constants ────────────────────────────────────────────────────
 _NAV_BG      = "#0f0f1e"
@@ -81,6 +82,7 @@ class LFMRIWindow(QMainWindow):
         seq_file: str | None = None,
         orchestrator_url: str = "http://localhost:1717",
         seq_interp_url: str = "http://localhost:7475",
+        spectroscopy_url: str = "http://localhost:8002",
     ) -> None:
         super().__init__()
         self.setWindowTitle("LF-MRI System")
@@ -91,23 +93,25 @@ class LFMRIWindow(QMainWindow):
         self._server_worker: ServerWorker | None = None
 
         # ── tabs ──────────────────────────────────────────────────────────
-        self._seq_tab      = SeqInterpTab(hw_config_path=hw_config_path,
-                                          output_dir=output_dir,
-                                          seq_interp_url=seq_interp_url)
-        self._scanner_tab  = ScannerTab(hw_config_path=hw_config_path,
-                                        orchestrator_url=orchestrator_url)
-        self._fid_tab      = FidTab(hw_config_path=hw_config_path,
-                                    output_dir=output_dir)
-        self._scanning_tab = ScanningTab()
+        self._seq_tab          = SeqInterpTab(hw_config_path=hw_config_path,
+                                              output_dir=output_dir,
+                                              seq_interp_url=seq_interp_url)
+        self._scanner_tab      = ScannerTab(hw_config_path=hw_config_path,
+                                            orchestrator_url=orchestrator_url)
+        self._fid_tab          = FidTab(hw_config_path=hw_config_path,
+                                        output_dir=output_dir)
+        self._scanning_tab     = ScanningTab()
         self._scanning_tab.set_orchestrator_url(orchestrator_url)
+        self._spectroscopy_tab = SpectroscopyTab(spectroscopy_url=spectroscopy_url)
 
         self._tabs = QTabWidget()
         self._tabs.tabBar().hide()          # driven by our custom nav bar
         self._tabs.setDocumentMode(True)    # removes the frame / pane border
-        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._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.currentChanged.connect(self._on_tab_changed)
         self.setCentralWidget(self._tabs)
 
@@ -401,7 +405,8 @@ class LFMRIWindow(QMainWindow):
             "&nbsp;&nbsp;<b>Sequence</b> — Pulseq interpreter, waveform viewer, export<br>"
             "&nbsp;&nbsp;<b>Scanner</b> — Hardware connection &amp; acquisition control<br>"
             "&nbsp;&nbsp;<b>FID</b> — FID sequence generator and visualiser<br>"
-            "&nbsp;&nbsp;<b>Scanning</b> — Clinical MRI viewer (scanning simulation)<br><br>"
+            "&nbsp;&nbsp;<b>Scanning</b> — Clinical MRI viewer (scanning simulation)<br>"
+            "&nbsp;&nbsp;<b>Spectroscopy</b> — NMR signal analysis: BPF → IQ-demod → FFT, peak/FWHM<br><br>"
             "Hardware → Start API Server exposes REST endpoints on port 7475.<br><br>"
             "Built with PySide6 · pyqtgraph · pypulseq",
         )

+ 199 - 0
apps/gui/src/clients/spectroscopy_client.py

@@ -0,0 +1,199 @@
+"""
+Synchronous HTTP client for the lf-spectroscopy service (port 8002 by default).
+
+All methods are blocking and intended to be called from a QThread worker.
+"""
+from __future__ import annotations
+
+import os
+
+
+class SpectroscopyError(Exception):
+    def __init__(self, message: str, status_code: int | None = None) -> None:
+        super().__init__(message)
+        self.status_code = status_code
+
+
+class SpectroscopyClient:
+    """Thin wrapper around the lf-spectroscopy REST API."""
+
+    def __init__(self, base_url: str = "http://localhost:8002") -> None:
+        self.base_url = base_url.rstrip("/")
+
+    # ── internals ─────────────────────────────────────────────────────────
+
+    def _get(self, path: str) -> dict:
+        import httpx
+        try:
+            r = httpx.get(
+                f"{self.base_url}{path}",
+                timeout=httpx.Timeout(connect=3.0, read=120.0, write=10.0, pool=5.0),
+            )
+        except httpx.ConnectError as exc:
+            raise SpectroscopyError(
+                f"Cannot connect to spectroscopy service at {self.base_url}"
+            ) from exc
+        except httpx.TimeoutException as exc:
+            raise SpectroscopyError("Spectroscopy request timed out") from exc
+        if not r.is_success:
+            raise SpectroscopyError(r.text, status_code=r.status_code)
+        return r.json()
+
+    def _post_form_file(self, path: str, file_path: str, fields: dict) -> dict:
+        """POST multipart/form-data with a file and extra form fields."""
+        import httpx
+        filename = os.path.basename(file_path)
+        try:
+            with open(file_path, "rb") as fh:
+                r = httpx.post(
+                    f"{self.base_url}{path}",
+                    data=fields,
+                    files={"file": (filename, fh, "application/octet-stream")},
+                    timeout=httpx.Timeout(connect=3.0, read=300.0, write=60.0, pool=5.0),
+                )
+        except httpx.ConnectError as exc:
+            raise SpectroscopyError(
+                f"Cannot connect to spectroscopy service at {self.base_url}"
+            ) from exc
+        except httpx.TimeoutException as exc:
+            raise SpectroscopyError("Spectroscopy upload timed out") from exc
+        if not r.is_success:
+            raise SpectroscopyError(r.text, status_code=r.status_code)
+        return r.json()
+
+    def _post_form(self, path: str, fields: dict) -> dict:
+        """POST multipart/form-data (no file)."""
+        import httpx
+        try:
+            r = httpx.post(
+                f"{self.base_url}{path}",
+                data=fields,
+                timeout=httpx.Timeout(connect=3.0, read=300.0, write=10.0, pool=5.0),
+            )
+        except httpx.ConnectError as exc:
+            raise SpectroscopyError(
+                f"Cannot connect to spectroscopy service at {self.base_url}"
+            ) from exc
+        except httpx.TimeoutException as exc:
+            raise SpectroscopyError("Spectroscopy request timed out") from exc
+        if not r.is_success:
+            raise SpectroscopyError(r.text, status_code=r.status_code)
+        return r.json()
+
+    # ── public API ────────────────────────────────────────────────────────
+
+    def healthcheck(self) -> bool:
+        """Return True if the service responds on /health."""
+        try:
+            self._get("/health")
+            return True
+        except SpectroscopyError:
+            return False
+
+    def analyze(
+        self,
+        json_file_path: str,
+        *,
+        center_freq:   float = 2.95e6,
+        bandpass_bw:   float = 0.1e6,
+        lp_cutoff:     float = 0.6e6,
+        dec_factor:    int   = 10,
+        zero_pad:      int   = 500_001,
+        butter_order:  int   = 4,
+        voltage_range: float = 5.0,
+        averaging_num: int   = 0,
+        data_num:      int   = 0,
+        channel_num:   int   = 1,
+    ) -> dict:
+        """
+        Upload a hardware JSON file and run full NMR analysis.
+
+        Returns a dict with keys:
+          metadata, raw_time, demod_time, spectrum, metrics
+        """
+        fields = {
+            "center_freq":   str(center_freq),
+            "bandpass_bw":   str(bandpass_bw),
+            "lp_cutoff":     str(lp_cutoff),
+            "dec_factor":    str(dec_factor),
+            "zero_pad":      str(zero_pad),
+            "butter_order":  str(butter_order),
+            "voltage_range": str(voltage_range),
+            "averaging_num": str(averaging_num),
+            "data_num":      str(data_num),
+            "channel_num":   str(channel_num),
+        }
+        return self._post_form_file("/analyze/", json_file_path, fields)
+
+    def analyze_from_params(self, json_file_path: str, params: dict) -> dict:
+        """
+        Convenience wrapper: pass params as a plain dict (matching the
+        keys of NMRParams) instead of keyword arguments.
+
+        Recognised keys: center_freq, bandpass_bw, lp_cutoff, dec_factor,
+        zero_pad, butter_order, voltage_range, averaging_num, data_num, channel_num.
+        """
+        return self.analyze(json_file_path, **{
+            k: params[k] for k in (
+                "center_freq", "bandpass_bw", "lp_cutoff", "dec_factor",
+                "zero_pad", "butter_order", "voltage_range",
+                "averaging_num", "data_num", "channel_num",
+            ) if k in params
+        })
+
+    def submit_batch(
+        self,
+        folder_path: str,
+        *,
+        center_freq:   float = 2.95e6,
+        bandpass_bw:   float = 0.1e6,
+        lp_cutoff:     float = 0.6e6,
+        dec_factor:    int   = 10,
+        zero_pad:      int   = 500_001,
+        butter_order:  int   = 4,
+        voltage_range: float = 5.0,
+        averaging_num: int   = 0,
+        data_num:      int   = 0,
+        channel_num:   int   = 1,
+    ) -> str:
+        """
+        Start batch NMR analysis for all *.json files in folder_path.
+        Returns batch_id (str).
+        """
+        fields = {
+            "folder_path":   folder_path,
+            "center_freq":   str(center_freq),
+            "bandpass_bw":   str(bandpass_bw),
+            "lp_cutoff":     str(lp_cutoff),
+            "dec_factor":    str(dec_factor),
+            "zero_pad":      str(zero_pad),
+            "butter_order":  str(butter_order),
+            "voltage_range": str(voltage_range),
+            "averaging_num": str(averaging_num),
+            "data_num":      str(data_num),
+            "channel_num":   str(channel_num),
+        }
+        data = self._post_form("/batch/", fields)
+        return data["batch_id"]
+
+    def submit_batch_from_params(self, folder_path: str, params: dict) -> str:
+        """Convenience wrapper using params dict (see submit_batch)."""
+        return self.submit_batch(folder_path, **{
+            k: params[k] for k in (
+                "center_freq", "bandpass_bw", "lp_cutoff", "dec_factor",
+                "zero_pad", "butter_order", "voltage_range",
+                "averaging_num", "data_num", "channel_num",
+            ) if k in params
+        })
+
+    def get_batch(self, batch_id: str) -> dict:
+        """
+        Poll batch analysis status and results.
+
+        Returns a dict with keys:
+          status       — "processing" | "completed"
+          progress     — {done: int, total: int}
+          results      — list of per-file dicts
+          summary      — {mean_peak_freq_khz, std_peak_freq_khz, mean_fwhm_khz, n_ok, n_error}
+        """
+        return self._get(f"/batch/{batch_id}")

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

@@ -0,0 +1,960 @@
+"""
+spectroscopy_tab.py — Spectroscopy tab (NMR signal analysis).
+
+Connects to the lf-spectroscopy service (POST /analyze/, default :8002)
+and shows three interactive pyqtgraph plots:
+
+  1. Raw Signal        — time-domain real + envelope  [ms]
+  2. Demodulated FID   — BPF → IQ-demod → LPF → decimation  [ms]
+  3. NMR Spectrum      — zero-pad + FFT, magnitude, peak marker + FWHM  [kHz]
+
+Left panel exposes all NMR parameters so the user can rerun analysis
+without reloading the file.
+
+Batch mode:  'Batch…' button → BatchDialog → POST /batch/ + polling.
+Export:      MATLAB (.mat) / CSV / NPZ.
+"""
+from __future__ import annotations
+
+import os
+
+import numpy as np
+import pyqtgraph as pg
+from PySide6.QtCore import Qt, QThread, QTimer
+from PySide6.QtGui import QColor, QFont
+from PySide6.QtWidgets import (
+    QCheckBox, QDialog, QDoubleSpinBox,
+    QFileDialog, QFormLayout, QFrame, QGroupBox,
+    QHBoxLayout, QHeaderView, QLabel, QLineEdit,
+    QMessageBox, QProgressBar, QPushButton,
+    QScrollArea, QSizePolicy, QSpinBox,
+    QSplitter, QTableWidget, QTableWidgetItem,
+    QVBoxLayout, QWidget,
+)
+
+from src.clients.spectroscopy_client import SpectroscopyClient, SpectroscopyError
+from src.gui.workers import OrchestratorWorker
+from src.gui.scheme_panel import system_is_dark
+
+
+# ── Colour palette ─────────────────────────────────────────────────────────────
+_C_RAW_REAL  = "#00cec9"   # Raw signal real      — cyan
+_C_RAW_ENV   = "#f0c040"   # Raw signal envelope  — yellow
+_C_DEM_REAL  = "#00b894"   # Demod FID real       — green
+_C_DEM_IMAG  = "#e84393"   # Demod FID imag       — pink
+_C_DEM_ENV   = "#fdcb6e"   # Demod FID envelope   — orange
+_C_SPEC_MAG  = "#dfe6e9"   # NMR spectrum mag     — near-white
+_C_SPEC_PHA  = "#a29bfe"   # NMR spectrum phase   — lavender
+_C_PEAK      = "#e74c3c"   # Peak marker          — red
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  SpectroscopyTab
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class SpectroscopyTab(QWidget):
+    """
+    NMR spectroscopy analysis tab.
+
+    Constructor parameters
+    ----------------------
+    spectroscopy_url : str
+        Base URL for the lf-spectroscopy service  (default :8002).
+    """
+
+    def __init__(
+        self,
+        spectroscopy_url: str = "http://localhost:8002",
+        parent: QWidget | None = None,
+    ) -> None:
+        super().__init__(parent)
+        self._client = SpectroscopyClient(spectroscopy_url)
+        self._worker: QThread | None = None
+        self._last_result: dict | None = None
+        self._last_json_path: str | None = None
+
+        root = QVBoxLayout(self)
+        root.setContentsMargins(0, 0, 0, 0)
+        root.setSpacing(0)
+        root.addWidget(self._build_toolbar())
+        root.addWidget(self._build_main(), stretch=1)
+        root.addWidget(self._build_statusbar())
+
+    # ── Public API ────────────────────────────────────────────────────────
+
+    def set_spectroscopy_url(self, url: str) -> None:
+        self._client = SpectroscopyClient(url)
+
+    def current_params(self) -> dict:
+        """Return current NMR parameters as a plain dict."""
+        return {
+            "center_freq":   self._sb_fc.value()   * 1e6,   # MHz → Hz
+            "bandpass_bw":   self._sb_bw.value()   * 1e3,   # kHz → Hz
+            "lp_cutoff":     self._sb_lp.value()   * 1e3,   # kHz → Hz
+            "dec_factor":    self._sb_dec.value(),
+            "zero_pad":      self._sb_zp.value(),
+            "butter_order":  4,
+            "voltage_range": 5.0,
+            "averaging_num": self._sb_avg.value(),
+            "data_num":      self._sb_dnum.value(),
+            "channel_num":   self._sb_ch.value(),
+        }
+
+    # ── Toolbar ───────────────────────────────────────────────────────────
+
+    def _build_toolbar(self) -> QWidget:
+        bar = QWidget()
+        bar.setObjectName("SpecToolBar")
+        bar.setStyleSheet(
+            "#SpecToolBar {"
+            "  background: palette(window);"
+            "  border-bottom: 1px solid palette(mid);"
+            "}"
+        )
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(6, 4, 6, 4)
+        lay.setSpacing(6)
+
+        self._btn_load = QPushButton("Load JSON…")
+        self._btn_load.setToolTip("Load a hardware JSON file and run NMR analysis")
+        self._btn_load.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+        self._btn_load.clicked.connect(self._load_json)
+        lay.addWidget(self._btn_load)
+
+        self._btn_analyze = QPushButton("Analyze")
+        self._btn_analyze.setToolTip(
+            "Re-run analysis with the current parameters\n"
+            "(same file, updated settings)"
+        )
+        self._btn_analyze.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+        self._btn_analyze.setEnabled(False)
+        self._btn_analyze.clicked.connect(self._analyze)
+        lay.addWidget(self._btn_analyze)
+
+        lay.addWidget(_vsep())
+
+        self._btn_batch = QPushButton("Batch…")
+        self._btn_batch.setToolTip("Process a whole folder of hardware JSON files")
+        self._btn_batch.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+        self._btn_batch.clicked.connect(self._open_batch_dialog)
+        lay.addWidget(self._btn_batch)
+
+        lay.addWidget(_vsep())
+
+        self._btn_export = QPushButton("Export…")
+        self._btn_export.setToolTip("Save analysis results (.mat / .csv / .npz)")
+        self._btn_export.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+        self._btn_export.setEnabled(False)
+        self._btn_export.clicked.connect(self._export_results)
+        lay.addWidget(self._btn_export)
+
+        self._btn_clear = QPushButton("Clear")
+        self._btn_clear.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+        self._btn_clear.clicked.connect(self._clear_plots)
+        lay.addWidget(self._btn_clear)
+
+        lay.addStretch()
+
+        self._progress = QProgressBar()
+        self._progress.setRange(0, 0)
+        self._progress.setFixedWidth(120)
+        self._progress.setVisible(False)
+        lay.addWidget(self._progress)
+
+        return bar
+
+    # ── Main splitter ─────────────────────────────────────────────────────
+
+    def _build_main(self) -> QSplitter:
+        split = QSplitter(Qt.Horizontal)
+        split.addWidget(self._build_left_panel())
+        split.addWidget(self._build_plots_panel())
+        split.setSizes([230, 900])
+        return split
+
+    # ── Left panel ────────────────────────────────────────────────────────
+
+    def _build_left_panel(self) -> QScrollArea:
+        inner = QWidget()
+        inner.setMinimumWidth(200)
+        inner.setMaximumWidth(290)
+        lay = QVBoxLayout(inner)
+        lay.setContentsMargins(6, 6, 6, 6)
+        lay.setSpacing(8)
+
+        # ── NMR Parameters ───────────────────────────────────────────────
+        nmr_grp = QGroupBox("NMR Parameters")
+        nmr_frm = QFormLayout(nmr_grp)
+        nmr_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+
+        self._sb_fc = QDoubleSpinBox()
+        self._sb_fc.setRange(0.001, 100.0)
+        self._sb_fc.setDecimals(4)
+        self._sb_fc.setSuffix(" MHz")
+        self._sb_fc.setSingleStep(0.01)
+        self._sb_fc.setValue(2.95)
+        self._sb_fc.setToolTip("Carrier / Larmor frequency")
+
+        self._sb_bw = QDoubleSpinBox()
+        self._sb_bw.setRange(1.0, 5000.0)
+        self._sb_bw.setDecimals(1)
+        self._sb_bw.setSuffix(" kHz")
+        self._sb_bw.setSingleStep(10.0)
+        self._sb_bw.setValue(100.0)
+        self._sb_bw.setToolTip("Bandpass filter bandwidth (fc ± BW/2)")
+
+        self._sb_lp = QDoubleSpinBox()
+        self._sb_lp.setRange(1.0, 5000.0)
+        self._sb_lp.setDecimals(1)
+        self._sb_lp.setSuffix(" kHz")
+        self._sb_lp.setSingleStep(10.0)
+        self._sb_lp.setValue(600.0)
+        self._sb_lp.setToolTip("Low-pass cutoff after IQ demodulation")
+
+        self._sb_dec = QSpinBox()
+        self._sb_dec.setRange(1, 1000)
+        self._sb_dec.setPrefix("×")
+        self._sb_dec.setValue(10)
+        self._sb_dec.setToolTip("Decimation factor")
+
+        self._sb_zp = QSpinBox()
+        self._sb_zp.setRange(0, 10_000_000)
+        self._sb_zp.setValue(500_001)
+        self._sb_zp.setSingleStep(10_000)
+        self._sb_zp.setToolTip("Zero-padding length for FFT resolution")
+
+        self._sb_avg = QSpinBox()
+        self._sb_avg.setRange(0, 9999)
+        self._sb_avg.setValue(0)
+        self._sb_avg.setToolTip("averaging_num field in hardware JSON")
+
+        self._sb_dnum = QSpinBox()
+        self._sb_dnum.setRange(0, 9999)
+        self._sb_dnum.setValue(0)
+        self._sb_dnum.setToolTip("data_num field in hardware JSON")
+
+        self._sb_ch = QSpinBox()
+        self._sb_ch.setRange(0, 15)
+        self._sb_ch.setValue(1)
+        self._sb_ch.setToolTip("channel_num field in hardware JSON")
+
+        nmr_frm.addRow("Center freq:", self._sb_fc)
+        nmr_frm.addRow("BW:",          self._sb_bw)
+        nmr_frm.addRow("LPF:",         self._sb_lp)
+        nmr_frm.addRow("Decimation:",  self._sb_dec)
+        nmr_frm.addRow("Zero pad:",    self._sb_zp)
+        nmr_frm.addRow("Avg num:",     self._sb_avg)
+        nmr_frm.addRow("Data num:",    self._sb_dnum)
+        nmr_frm.addRow("Channel:",     self._sb_ch)
+        lay.addWidget(nmr_grp)
+
+        # ── Metadata ──────────────────────────────────────────────────────
+        meta_grp = QGroupBox("Metadata")
+        meta_frm = QFormLayout(meta_grp)
+        meta_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+        self._lbl_n   = QLabel("—")
+        self._lbl_fs  = QLabel("—")
+        self._lbl_dur = QLabel("—")
+        self._lbl_dec = QLabel("—")
+        meta_frm.addRow("Samples:",  self._lbl_n)
+        meta_frm.addRow("Rate:",     self._lbl_fs)
+        meta_frm.addRow("Duration:", self._lbl_dur)
+        meta_frm.addRow("Dec ×:",    self._lbl_dec)
+        lay.addWidget(meta_grp)
+
+        # ── Metrics ───────────────────────────────────────────────────────
+        met_grp = QGroupBox("Metrics")
+        met_frm = QFormLayout(met_grp)
+        met_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+        mono9 = QFont("Courier New", 9)
+        self._lbl_peak_f   = QLabel("—"); self._lbl_peak_f.setFont(mono9)
+        self._lbl_fwhm     = QLabel("—"); self._lbl_fwhm.setFont(mono9)
+        self._lbl_peak_amp = QLabel("—"); self._lbl_peak_amp.setFont(mono9)
+        met_frm.addRow("Peak freq:",  self._lbl_peak_f)
+        met_frm.addRow("FWHM:",       self._lbl_fwhm)
+        met_frm.addRow("Amplitude:",  self._lbl_peak_amp)
+        lay.addWidget(met_grp)
+
+        lay.addStretch()
+
+        # ── Cursor readout ─────────────────────────────────────────────────
+        cur_grp = QGroupBox("Cursor")
+        cur_lay = QVBoxLayout(cur_grp)
+        cur_lay.setSpacing(2)
+        mono8 = QFont("Courier New", 8)
+        self._lbl_c1 = QLabel("—"); self._lbl_c1.setFont(mono8); self._lbl_c1.setWordWrap(True)
+        self._lbl_c2 = QLabel("—"); self._lbl_c2.setFont(mono8); self._lbl_c2.setWordWrap(True)
+        self._lbl_c3 = QLabel("—"); self._lbl_c3.setFont(mono8); self._lbl_c3.setWordWrap(True)
+        cur_lay.addWidget(QLabel("Raw:"))
+        cur_lay.addWidget(self._lbl_c1)
+        cur_lay.addWidget(QLabel("Demod:"))
+        cur_lay.addWidget(self._lbl_c2)
+        cur_lay.addWidget(QLabel("Spectrum:"))
+        cur_lay.addWidget(self._lbl_c3)
+        lay.addWidget(cur_grp)
+
+        scroll = QScrollArea()
+        scroll.setWidget(inner)
+        scroll.setWidgetResizable(True)
+        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        return scroll
+
+    # ── Plots panel ───────────────────────────────────────────────────────
+
+    def _build_plots_panel(self) -> QSplitter:
+        _dark = system_is_dark()
+        pg.setConfigOptions(
+            antialias=True,
+            background=QColor("#1e1e1e") if _dark else QColor("#ffffff"),
+            foreground="w" if _dark else "k",
+        )
+        _cross = pg.mkPen(
+            "#ffffff50" if _dark else "#00000050", width=1, style=Qt.DashLine
+        )
+
+        vsplit = QSplitter(Qt.Vertical)
+
+        # ── Plot 1 — Raw Signal ────────────────────────────────────────────
+        self._raw_widget = pg.PlotWidget()
+        p1 = self._raw_widget.getPlotItem()
+        p1.setTitle("Raw Signal")
+        p1.setLabel("bottom", "Time", units="ms")
+        p1.setLabel("left",   "Amplitude", units="V")
+        p1.showGrid(x=True, y=True, alpha=0.2)
+        p1.addLegend(offset=(10, 5))
+        self._raw_real = p1.plot(pen=pg.mkPen(_C_RAW_REAL, width=1.5), name="Real")
+        self._raw_env  = p1.plot(pen=pg.mkPen(_C_RAW_ENV,  width=1.5), name="Envelope")
+        self._raw_vl = pg.InfiniteLine(angle=90, movable=False, pen=_cross)
+        self._raw_hl = pg.InfiniteLine(angle=0,  movable=False, pen=_cross)
+        p1.addItem(self._raw_vl, ignoreBounds=True)
+        p1.addItem(self._raw_hl, ignoreBounds=True)
+        self._raw_widget.scene().sigMouseMoved.connect(self._on_raw_mouse)
+        vsplit.addWidget(self._raw_widget)
+
+        # ── Plot 2 — Demodulated FID ───────────────────────────────────────
+        self._dem_widget = pg.PlotWidget()
+        p2 = self._dem_widget.getPlotItem()
+        p2.setTitle("Demodulated FID")
+        p2.setLabel("bottom", "Time", units="ms")
+        p2.setLabel("left",   "Amplitude", units="a.u.")
+        p2.showGrid(x=True, y=True, alpha=0.2)
+        p2.addLegend(offset=(10, 5))
+        self._dem_real = p2.plot(pen=pg.mkPen(_C_DEM_REAL, width=1.5), name="Real")
+        self._dem_imag = p2.plot(pen=pg.mkPen(_C_DEM_IMAG, width=1.5), name="Imag")
+        self._dem_env  = p2.plot(pen=pg.mkPen(_C_DEM_ENV,  width=1.5), name="Envelope")
+        self._dem_vl = pg.InfiniteLine(angle=90, movable=False, pen=_cross)
+        self._dem_hl = pg.InfiniteLine(angle=0,  movable=False, pen=_cross)
+        p2.addItem(self._dem_vl, ignoreBounds=True)
+        p2.addItem(self._dem_hl, ignoreBounds=True)
+        self._dem_widget.scene().sigMouseMoved.connect(self._on_dem_mouse)
+        vsplit.addWidget(self._dem_widget)
+
+        # ── Plot 3 — NMR Spectrum ──────────────────────────────────────────
+        self._sp_widget = pg.PlotWidget()
+        p3 = self._sp_widget.getPlotItem()
+        p3.setTitle("NMR Spectrum")
+        p3.setLabel("bottom", "Frequency", units="kHz")
+        p3.setLabel("left",   "Magnitude", units="a.u.")
+        p3.showGrid(x=True, y=True, alpha=0.2)
+        p3.addLegend(offset=(10, 5))
+        self._sp_mag   = p3.plot(pen=pg.mkPen(_C_SPEC_MAG, width=1.5), name="Magnitude")
+        self._sp_phase = p3.plot(pen=pg.mkPen(_C_SPEC_PHA, width=1.0), name="Phase (rad)")
+        self._sp_phase.setVisible(False)
+
+        # Peak vertical line
+        self._peak_line = pg.InfiniteLine(
+            angle=90, movable=False,
+            pen=pg.mkPen(_C_PEAK, width=1.5, style=Qt.DashLine),
+        )
+        self._peak_line.setVisible(False)
+        p3.addItem(self._peak_line)
+
+        # FWHM shaded region
+        self._fwhm_region = pg.LinearRegionItem(
+            values=[-1.0, 1.0],
+            movable=False,
+            brush=pg.mkBrush(231, 76, 60, 40),
+            pen=pg.mkPen(_C_PEAK, width=0.5),
+        )
+        self._fwhm_region.setVisible(False)
+        p3.addItem(self._fwhm_region)
+
+        self._sp_vl = pg.InfiniteLine(angle=90, movable=False, pen=_cross)
+        self._sp_hl = pg.InfiniteLine(angle=0,  movable=False, pen=_cross)
+        p3.addItem(self._sp_vl, ignoreBounds=True)
+        p3.addItem(self._sp_hl, ignoreBounds=True)
+        self._sp_widget.scene().sigMouseMoved.connect(self._on_sp_mouse)
+        vsplit.addWidget(self._sp_widget)
+
+        vsplit.setSizes([250, 250, 300])
+        return vsplit
+
+    # ── Status bar ────────────────────────────────────────────────────────
+
+    def _build_statusbar(self) -> QFrame:
+        bar = QFrame()
+        bar.setFrameShape(QFrame.StyledPanel)
+        bar.setFixedHeight(22)
+        lay = QHBoxLayout(bar)
+        lay.setContentsMargins(6, 0, 6, 0)
+        self._status_lbl = QLabel(
+            "Click 'Load JSON…' to open a hardware JSON file and start NMR analysis"
+        )
+        self._status_lbl.setFont(QFont("Arial", 8))
+        lay.addWidget(self._status_lbl)
+        lay.addStretch()
+        return bar
+
+    # ── Actions ───────────────────────────────────────────────────────────
+
+    def _load_json(self) -> None:
+        path, _ = QFileDialog.getOpenFileName(
+            self, "Load hardware JSON", "",
+            "JSON files (*.json);;All files (*)"
+        )
+        if not path:
+            return
+        self._last_json_path = path
+        self._btn_analyze.setEnabled(True)
+        self._run_analysis(path)
+
+    def _analyze(self) -> None:
+        if self._last_json_path:
+            self._run_analysis(self._last_json_path)
+
+    def _run_analysis(self, path: str) -> None:
+        fname = os.path.basename(path)
+        self._set_busy(True, f"Analyzing {fname}…")
+        params = self.current_params()
+        self._worker = OrchestratorWorker(
+            self._client.analyze_from_params, path, params
+        )
+        self._worker.finished.connect(self._on_result)
+        self._worker.error.connect(self._on_error)
+        self._worker.start()
+
+    def _open_batch_dialog(self) -> None:
+        dlg = BatchDialog(self._client, self.current_params(), parent=self)
+        dlg.exec()
+
+    def _export_results(self) -> None:
+        if not self._last_result:
+            return
+        path, _ = QFileDialog.getSaveFileName(
+            self, "Save analysis results", "",
+            "MATLAB files (*.mat);;CSV files (*.csv);;NumPy archive (*.npz)"
+        )
+        if not path:
+            return
+        try:
+            if path.endswith(".mat"):
+                _export_mat(path, self._last_result)
+            elif path.endswith(".csv"):
+                _export_csv_single(path, self._last_result)
+            else:
+                _export_npz(path, self._last_result)
+            self._status(f"Saved → {os.path.basename(path)}")
+        except Exception as exc:
+            QMessageBox.critical(self, "Export error", str(exc))
+
+    def _clear_plots(self) -> None:
+        empty: list = []
+        for curve in (
+            self._raw_real, self._raw_env,
+            self._dem_real, self._dem_imag, self._dem_env,
+            self._sp_mag, self._sp_phase,
+        ):
+            curve.setData(empty, empty)
+        self._peak_line.setVisible(False)
+        self._fwhm_region.setVisible(False)
+        self._last_result = None
+        self._btn_export.setEnabled(False)
+        self._update_metadata(None)
+        self._update_metrics(None)
+        self._status("Cleared")
+
+    # ── Result handling ───────────────────────────────────────────────────
+
+    def _on_result(self, result: dict) -> None:
+        self._set_busy(False)
+        self._last_result = result
+        self._btn_export.setEnabled(True)
+        self._plot_result(result)
+        m = result.get("metrics", {})
+        self._status(
+            f"OK — peak {m.get('peak_freq_khz', 0):.3f} kHz  |  "
+            f"FWHM {m.get('fwhm_khz', 0) * 1e3:.1f} Hz  |  "
+            f"{os.path.basename(self._last_json_path or '')}"
+        )
+
+    def _plot_result(self, result: dict) -> None:
+        raw = result.get("raw_time",   {})
+        dem = result.get("demod_time", {})
+        sp  = result.get("spectrum",   {})
+        met = result.get("metrics",    {})
+
+        # ── Raw ──────────────────────────────────────────────────────────
+        t_r = raw.get("t_ms", [])
+        self._raw_real.setData(t_r, raw.get("real",      []))
+        self._raw_env. setData(t_r, raw.get("amplitude", []))
+
+        # ── Demod FID ────────────────────────────────────────────────────
+        t_d = dem.get("t_ms", [])
+        self._dem_real.setData(t_d, dem.get("real",      []))
+        self._dem_imag.setData(t_d, dem.get("imag",      []))
+        self._dem_env. setData(t_d, dem.get("amplitude", []))
+
+        # ── Spectrum ─────────────────────────────────────────────────────
+        f_k = sp.get("freq_khz", [])
+        self._sp_mag.  setData(f_k, sp.get("magnitude", []))
+        self._sp_phase.setData(f_k, sp.get("phase_rad", []))
+
+        # Peak marker + FWHM shading
+        peak_f   = float(met.get("peak_freq_khz", 0))
+        fwhm_khz = float(met.get("fwhm_khz",     0))
+        self._peak_line.setValue(peak_f)
+        self._peak_line.setVisible(True)
+        hw = fwhm_khz / 2.0
+        self._fwhm_region.setRegion([peak_f - hw, peak_f + hw])
+        self._fwhm_region.setVisible(True)
+
+        self._update_metadata(result.get("metadata"))
+        self._update_metrics(met)
+
+    def _update_metadata(self, meta: dict | None) -> None:
+        if meta is None:
+            for lbl in (self._lbl_n, self._lbl_fs, self._lbl_dur, self._lbl_dec):
+                lbl.setText("—")
+            return
+        self._lbl_n.  setText(str(meta.get("n_samples", "—")))
+        fs = meta.get("sample_rate_hz")
+        self._lbl_fs. setText(f"{fs / 1e6:.3f} MHz" if fs else "—")
+        dur = meta.get("duration_ms")
+        self._lbl_dur.setText(f"{dur:.4f} ms"       if dur is not None else "—")
+        self._lbl_dec.setText(str(meta.get("dec_factor_actual", "—")))
+
+    def _update_metrics(self, met: dict | None) -> None:
+        if met is None:
+            for lbl in (self._lbl_peak_f, self._lbl_fwhm, self._lbl_peak_amp):
+                lbl.setText("—")
+            return
+        pf = met.get("peak_freq_khz")
+        fw = met.get("fwhm_khz")
+        pa = met.get("peak_amplitude")
+        self._lbl_peak_f.  setText(f"{pf:.4f} kHz"      if pf is not None else "—")
+        self._lbl_fwhm.    setText(f"{fw * 1e3:.2f} Hz"  if fw is not None else "—")
+        self._lbl_peak_amp.setText(f"{pa:.4g}"            if pa is not None else "—")
+
+    def _on_error(self, msg: str) -> None:
+        self._set_busy(False)
+        self._status(f"ERROR: {msg}")
+        QMessageBox.critical(self, "Analysis error", msg)
+
+    # ── Mouse / crosshair ─────────────────────────────────────────────────
+
+    def _on_raw_mouse(self, pos) -> None:
+        if not self._raw_widget.sceneBoundingRect().contains(pos):
+            return
+        pt = self._raw_widget.getPlotItem().getViewBox().mapSceneToView(pos)
+        self._raw_vl.setPos(pt.x())
+        self._raw_hl.setPos(pt.y())
+        self._lbl_c1.setText(f"t={pt.x():.4f} ms\na={pt.y():.4g}")
+
+    def _on_dem_mouse(self, pos) -> None:
+        if not self._dem_widget.sceneBoundingRect().contains(pos):
+            return
+        pt = self._dem_widget.getPlotItem().getViewBox().mapSceneToView(pos)
+        self._dem_vl.setPos(pt.x())
+        self._dem_hl.setPos(pt.y())
+        self._lbl_c2.setText(f"t={pt.x():.4f} ms\na={pt.y():.4g}")
+
+    def _on_sp_mouse(self, pos) -> None:
+        if not self._sp_widget.sceneBoundingRect().contains(pos):
+            return
+        pt = self._sp_widget.getPlotItem().getViewBox().mapSceneToView(pos)
+        self._sp_vl.setPos(pt.x())
+        self._sp_hl.setPos(pt.y())
+        self._lbl_c3.setText(f"f={pt.x():.4f} kHz\nm={pt.y():.4g}")
+
+    # ── Helpers ───────────────────────────────────────────────────────────
+
+    def _set_busy(self, busy: bool, tip: str = "") -> None:
+        self._progress.setVisible(busy)
+        self._btn_load.   setEnabled(not busy)
+        self._btn_analyze.setEnabled(not busy and self._last_json_path is not None)
+        self._btn_batch.  setEnabled(not busy)
+        if tip:
+            self._status(tip)
+
+    def _status(self, msg: str) -> None:
+        self._status_lbl.setText(msg)
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  BatchDialog
+# ═══════════════════════════════════════════════════════════════════════════════
+
+class BatchDialog(QDialog):
+    """
+    Batch NMR analysis dialog.
+
+    Submits POST /batch/ → polls GET /batch/{id} every second until
+    completion, updating a QTableWidget with per-file results.
+    """
+
+    _COL_FILE = 0
+    _COL_PEAK = 1
+    _COL_FWHM = 2
+    _COL_AMP  = 3
+    _COL_ST   = 4
+
+    def __init__(
+        self,
+        client: SpectroscopyClient,
+        current_params: dict,
+        parent: QWidget | None = None,
+    ) -> None:
+        super().__init__(parent)
+        self.setWindowTitle("Batch NMR Analysis")
+        self.setMinimumSize(720, 540)
+
+        self._client         = client
+        self._current_params = current_params
+        self._batch_id: str | None = None
+        self._submit_worker: QThread | None = None
+        self._poll_worker:   QThread | None = None
+        self._timer = QTimer(self)
+        self._timer.setInterval(1000)
+        self._timer.timeout.connect(self._poll_batch)
+
+        self._build_ui()
+
+    # ── UI ────────────────────────────────────────────────────────────────
+
+    def _build_ui(self) -> None:
+        lay = QVBoxLayout(self)
+        lay.setSpacing(8)
+
+        # Folder picker
+        fold_row = QHBoxLayout()
+        fold_row.addWidget(QLabel("Folder:"))
+        self._le_folder = QLineEdit()
+        self._le_folder.setPlaceholderText("Path to folder containing *.json files")
+        fold_row.addWidget(self._le_folder, stretch=1)
+        btn_browse = QPushButton("Browse…")
+        btn_browse.clicked.connect(self._browse_folder)
+        fold_row.addWidget(btn_browse)
+        lay.addLayout(fold_row)
+
+        # Params toggle
+        self._cb_use_current = QCheckBox(
+            "Use current parameters from the Spectroscopy tab"
+        )
+        self._cb_use_current.setChecked(True)
+        self._cb_use_current.toggled.connect(self._on_use_current_toggled)
+        lay.addWidget(self._cb_use_current)
+
+        self._params_widget = self._build_batch_params()
+        self._params_widget.setVisible(False)
+        lay.addWidget(self._params_widget)
+
+        # Run row
+        run_row = QHBoxLayout()
+        self._btn_run = QPushButton("Run Batch")
+        self._btn_run.setFixedWidth(100)
+        self._btn_run.clicked.connect(self._run_batch)
+        run_row.addWidget(self._btn_run)
+        self._prog = QProgressBar()
+        self._prog.setValue(0)
+        run_row.addWidget(self._prog, stretch=1)
+        self._lbl_prog = QLabel("0 / 0")
+        self._lbl_prog.setFixedWidth(60)
+        run_row.addWidget(self._lbl_prog)
+        lay.addLayout(run_row)
+
+        # Results table
+        self._table = QTableWidget(0, 5)
+        self._table.setHorizontalHeaderLabels(
+            ["File", "Peak (kHz)", "FWHM (Hz)", "Amplitude", "Status"]
+        )
+        hdr = self._table.horizontalHeader()
+        hdr.setSectionResizeMode(self._COL_FILE, QHeaderView.Stretch)
+        for c in (self._COL_PEAK, self._COL_FWHM, self._COL_AMP, self._COL_ST):
+            hdr.setSectionResizeMode(c, QHeaderView.ResizeToContents)
+        self._table.setEditTriggers(QTableWidget.NoEditTriggers)
+        self._table.setSelectionBehavior(QTableWidget.SelectRows)
+        self._table.setAlternatingRowColors(True)
+        lay.addWidget(self._table, stretch=1)
+
+        # Bottom buttons
+        bot_row = QHBoxLayout()
+        self._btn_csv = QPushButton("Export CSV…")
+        self._btn_csv.setEnabled(False)
+        self._btn_csv.clicked.connect(self._export_csv)
+        bot_row.addWidget(self._btn_csv)
+        bot_row.addStretch()
+        btn_close = QPushButton("Close")
+        btn_close.clicked.connect(self.accept)
+        bot_row.addWidget(btn_close)
+        lay.addLayout(bot_row)
+
+        # Status line
+        self._status_lbl = QLabel("Select a folder and click Run Batch.")
+        lay.addWidget(self._status_lbl)
+
+    def _build_batch_params(self) -> QWidget:
+        w = QWidget()
+        frm = QFormLayout(w)
+        frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
+
+        def _dbl(lo, hi, suf, val, dec=1):
+            sb = QDoubleSpinBox()
+            sb.setRange(lo, hi)
+            sb.setDecimals(dec)
+            sb.setSuffix(f" {suf}")
+            sb.setValue(val)
+            return sb
+
+        def _int(lo, hi, val, pfx=""):
+            sb = QSpinBox()
+            sb.setRange(lo, hi)
+            sb.setValue(val)
+            if pfx:
+                sb.setPrefix(pfx)
+            return sb
+
+        self._b_fc  = _dbl(0.001, 100.0,    "MHz",  2.95,  dec=4)
+        self._b_bw  = _dbl(1.0,   5000.0,   "kHz",  100.0)
+        self._b_lp  = _dbl(1.0,   5000.0,   "kHz",  600.0)
+        self._b_dec = _int(1,     1000,      10,     pfx="×")
+        self._b_zp  = _int(0,     10_000_000, 500_001)
+        self._b_ch  = _int(0,     15,        1)
+
+        self._b_zp.setSingleStep(10_000)
+
+        frm.addRow("Center freq:", self._b_fc)
+        frm.addRow("BW:",          self._b_bw)
+        frm.addRow("LPF:",         self._b_lp)
+        frm.addRow("Decimation:",  self._b_dec)
+        frm.addRow("Zero pad:",    self._b_zp)
+        frm.addRow("Channel:",     self._b_ch)
+        return w
+
+    # ── Slots ─────────────────────────────────────────────────────────────
+
+    def _on_use_current_toggled(self, checked: bool) -> None:
+        self._params_widget.setVisible(not checked)
+
+    def _browse_folder(self) -> None:
+        p = QFileDialog.getExistingDirectory(self, "Select folder with JSON files")
+        if p:
+            self._le_folder.setText(p)
+
+    def _get_params(self) -> dict:
+        if self._cb_use_current.isChecked():
+            return dict(self._current_params)
+        return {
+            "center_freq":   self._b_fc.value()  * 1e6,
+            "bandpass_bw":   self._b_bw.value()  * 1e3,
+            "lp_cutoff":     self._b_lp.value()  * 1e3,
+            "dec_factor":    self._b_dec.value(),
+            "zero_pad":      self._b_zp.value(),
+            "butter_order":  4,
+            "voltage_range": 5.0,
+            "averaging_num": 0,
+            "data_num":      0,
+            "channel_num":   self._b_ch.value(),
+        }
+
+    def _run_batch(self) -> None:
+        folder = self._le_folder.text().strip()
+        if not folder:
+            QMessageBox.warning(self, "Batch", "Please select a folder.")
+            return
+        if not os.path.isdir(folder):
+            QMessageBox.warning(self, "Batch", f"Not a directory:\n{folder}")
+            return
+
+        self._table.setRowCount(0)
+        self._btn_run.setEnabled(False)
+        self._btn_csv.setEnabled(False)
+        self._prog.setValue(0)
+        self._lbl_prog.setText("0 / ?")
+        self._status_lbl.setText("Submitting batch job…")
+
+        params = self._get_params()
+        self._submit_worker = OrchestratorWorker(
+            self._client.submit_batch_from_params, folder, params
+        )
+        self._submit_worker.finished.connect(self._on_batch_submitted)
+        self._submit_worker.error.connect(self._on_error)
+        self._submit_worker.start()
+
+    def _on_batch_submitted(self, batch_id: str) -> None:
+        self._batch_id = batch_id
+        self._status_lbl.setText(f"Batch accepted  (id: {batch_id})")
+        self._timer.start()
+
+    def _poll_batch(self) -> None:
+        if self._poll_worker is not None and self._poll_worker.isRunning():
+            return  # previous poll still in-flight — skip tick
+        self._poll_worker = OrchestratorWorker(
+            self._client.get_batch, self._batch_id
+        )
+        self._poll_worker.finished.connect(self._on_poll_result)
+        self._poll_worker.error.connect(self._on_error)
+        self._poll_worker.start()
+
+    def _on_poll_result(self, data: dict) -> None:
+        prog    = data.get("progress", {})
+        done    = prog.get("done",  0)
+        total   = prog.get("total", 0)
+        status  = data.get("status",  "?")
+        results = data.get("results", [])
+        summary = data.get("summary", {})
+
+        # Progress bar
+        if total > 0:
+            self._prog.setMaximum(total)
+            self._prog.setValue(done)
+        self._lbl_prog.setText(f"{done} / {total}")
+        self._status_lbl.setText(f"Status: {status}  ({done}/{total})")
+
+        # Rebuild table rows
+        self._table.setRowCount(len(results))
+        for row, r in enumerate(results):
+            pf  = r.get("peak_freq_khz")
+            fw  = r.get("fwhm_khz")
+            pa  = r.get("peak_amplitude")
+            st  = r.get("status", "?")
+
+            self._table.setItem(
+                row, self._COL_FILE,
+                QTableWidgetItem(r.get("filename", "?")),
+            )
+            self._table.setItem(
+                row, self._COL_PEAK,
+                QTableWidgetItem(f"{pf:.4f}" if pf is not None else "—"),
+            )
+            self._table.setItem(
+                row, self._COL_FWHM,
+                QTableWidgetItem(f"{fw * 1e3:.2f}" if fw is not None else "—"),
+            )
+            self._table.setItem(
+                row, self._COL_AMP,
+                QTableWidgetItem(f"{pa:.4g}" if pa is not None else "—"),
+            )
+            it_st = QTableWidgetItem(st)
+            if st == "error":
+                it_st.setForeground(QColor("#e74c3c"))
+                it_st.setToolTip(r.get("error") or "")
+            else:
+                it_st.setForeground(QColor("#2ecc71"))
+            self._table.setItem(row, self._COL_ST, it_st)
+
+        if status == "completed":
+            self._timer.stop()
+            self._btn_run.setEnabled(True)
+            self._btn_csv.setEnabled(True)
+            # Summary line
+            n_ok  = summary.get("n_ok",  0)
+            n_err = summary.get("n_error", 0)
+            parts = [f"Done — {n_ok} ok, {n_err} error(s)"]
+            mn = summary.get("mean_peak_freq_khz")
+            sd = summary.get("std_peak_freq_khz")
+            mw = summary.get("mean_fwhm_khz")
+            if mn is not None:
+                parts.append(f"Peak: {mn:.3f} ± {sd:.3f} kHz")
+            if mw is not None:
+                parts.append(f"FWHM: {mw * 1e3:.1f} Hz")
+            self._status_lbl.setText("  |  ".join(parts))
+
+    def _on_error(self, msg: str) -> None:
+        self._timer.stop()
+        self._btn_run.setEnabled(True)
+        self._status_lbl.setText(f"ERROR: {msg}")
+        QMessageBox.critical(self, "Batch error", msg)
+
+    def _export_csv(self) -> None:
+        path, _ = QFileDialog.getSaveFileName(
+            self, "Save batch results", "", "CSV files (*.csv)"
+        )
+        if not path:
+            return
+        try:
+            rows = self._table.rowCount()
+            cols = self._table.columnCount()
+            headers = [
+                self._table.horizontalHeaderItem(c).text() for c in range(cols)
+            ]
+            with open(path, "w", encoding="utf-8") as fh:
+                fh.write(",".join(headers) + "\n")
+                for r in range(rows):
+                    vals = []
+                    for c in range(cols):
+                        it = self._table.item(r, c)
+                        vals.append(it.text() if it else "")
+                    fh.write(",".join(vals) + "\n")
+            QMessageBox.information(self, "Export", f"Saved: {path}")
+        except Exception as exc:
+            QMessageBox.critical(self, "Export error", str(exc))
+
+    def closeEvent(self, event) -> None:
+        self._timer.stop()
+        super().closeEvent(event)
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Export helpers
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def _export_mat(path: str, result: dict) -> None:
+    from scipy.io import savemat
+    mat: dict = {}
+    for section, prefix in (
+        ("raw_time",   "raw"),
+        ("demod_time", "demod"),
+        ("spectrum",   "spec"),
+    ):
+        for k, v in result.get(section, {}).items():
+            mat[f"{prefix}_{k}"] = np.asarray(v, dtype=float)
+    for k, v in result.get("metrics", {}).items():
+        mat[k] = float(v) if v is not None else 0.0
+    savemat(path, mat)
+
+
+def _export_csv_single(path: str, result: dict) -> None:
+    sp  = result.get("spectrum", {})
+    met = result.get("metrics",  {})
+    freq = sp.get("freq_khz",  [])
+    mag  = sp.get("magnitude", [])
+    with open(path, "w", encoding="utf-8") as fh:
+        for k, v in met.items():
+            fh.write(f"# {k}: {v}\n")
+        fh.write("freq_khz,magnitude\n")
+        for f, m in zip(freq, mag):
+            fh.write(f"{f},{m}\n")
+
+
+def _export_npz(path: str, result: dict) -> None:
+    arrays: dict = {}
+    for section, pfx in (
+        ("raw_time",   "raw"),
+        ("demod_time", "dem"),
+        ("spectrum",   "sp"),
+    ):
+        for k, v in result.get(section, {}).items():
+            arrays[f"{pfx}_{k}"] = np.asarray(v, dtype=float)
+    np.savez(path, **arrays)
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+#  Misc helpers
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def _vsep() -> QFrame:
+    f = QFrame()
+    f.setFrameShape(QFrame.VLine)
+    f.setFrameShadow(QFrame.Sunken)
+    f.setFixedWidth(2)
+    return f