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