#!/opt/srasenv/bin/python3 """ sc3_aui_app.py — Scanengine-3 AUI Loads sc3-aui-main.ui, sc3-aui-camera.ui, sc3-aui-scanprogress.ui via uic and wires up T3R, BBD202, oscilloscope, and camera hardware workers. """ import queue import struct import sys import time from pathlib import Path import threading from threading import Thread import numpy as np from PyQt6 import uic from PyQt6.QtCore import QObject, QThread, QTimer, Qt, pyqtSignal, pyqtSlot from PyQt6.QtGui import QImage, QPixmap from PyQt6.QtWidgets import ( QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton, QSizePolicy, QVBoxLayout, QWidget, ) from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure ROOT = Path(__file__).parent sys.path.insert(0, str(ROOT)) from core.config import ScanDefaults from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta, validate_plan from core.sras_format import ( SCAN_CHANNELS, SrasFile, create_scan_file, plan_from_header, ) from hardware.helios_laser import HeliosLaser from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver from hardware.t3r_driver import T3RDriver from hardware.tektronix_base import TektronixOscilloscopeBase from hardware.uc480_camera import ( CameraStreamThread, UC480Camera, find_camera_bus_conflicts, ) from t3r_control_panel import T3RControlPanel DEFAULTS = ScanDefaults.load() # ── Scan constants ─────────────────────────────────────────────────────────── SCAN_VELOCITY_MM_S = 100.0 SCAN_ACCEL_MM_S2 = 1500.0 LASER_FREQ_HZ = 20000.0 # laser pulse frequency during data acquisition # Theoretical ramp distance: d = v² / (2a) = 100² / (2×1500) ≈ 3.33 mm SCAN_RAMP_MM = SCAN_VELOCITY_MM_S**2 / (2.0 * SCAN_ACCEL_MM_S2) # Extra buffer added to both ends of the ramp. The BBD202 controller begins # decelerating slightly before the theoretical point to avoid overshoot, which # causes TRIGOUT_MAXV to drop early and clip the last few data points. # 1 mm at 100 mm/s and 2000 Hz corresponds to 20 extra trigger windows. SCAN_RAMP_BUFFER_MM = 1.0 SCOPE_SAMPLE_RATE = 6.25e9 # 6.25 GS/s → 160 ps/sample SCOPE_TRIG_LEVEL_V = 0.500 # GR-axis rotation defaults (used by ScanWorker between angles) GR_MICROSTEPS = 8 # microsteps/full-step on GR axis (ch3) GR_MOVE_VELOCITY = 4000 # steps/s for inter-angle moves GR_MOVE_ACCEL = 2000 # steps/s² for inter-angle moves GR_RUN_CURRENT_MA = 1200 # drive current while moving GR_HOLD_CURRENT_MA = 400 # standstill current GR_IHOLD_DELAY = 6 # run→hold current ramp delay (TMC IHOLDDELAY units) # Sample rotates CW instead of CCW to clear wiring and avoid a stall condition. GR_ROTATION_SIGN = -1 BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202 class DCBiasImageWidget(FigureCanvas): """Live 2D DC-bias image: rows × frames, matching sras_viewer's compute_dc_image view.""" def __init__(self, parent=None): fig = Figure(tight_layout=True) super().__init__(fig) self.setParent(parent) self.setMinimumSize(300, 150) self.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding, ) self.ax = fig.add_subplot(111) self._row_data: list[np.ndarray] = [] self._im = None self.ax.set_title("DC Bias Preview (ADC counts)") self.ax.set_xlabel("Frame") self.ax.set_ylabel("Row") self.ax.text(0.5, 0.5, "Waiting for data…", transform=self.ax.transAxes, ha="center", va="center", color="gray") self.draw() def add_row(self, row_index: int, values): """Store per-frame DC mean values for row_index (1-based) and refresh the image.""" idx = row_index - 1 while len(self._row_data) <= idx: self._row_data.append(np.empty(0, dtype=np.float32)) self._row_data[idx] = np.asarray(values, dtype=np.float32) # Build rectangular image — pad shorter rows with NaN so they render distinctly max_len = max(len(r) for r in self._row_data) img = np.full((len(self._row_data), max_len), np.nan, dtype=np.float32) for i, row in enumerate(self._row_data): img[i, :len(row)] = row self.ax.clear() self.ax.set_title("DC Bias Preview (ADC counts)") self.ax.set_xlabel("Frame") self.ax.set_ylabel("Row") self.ax.imshow( img, aspect="auto", origin="upper", cmap="viridis", interpolation="nearest", ) self.draw_idle() BBD_JOG_SPEED_MM_S = 10.0 BBD_JOG_ACCEL_MM_S2 = 50.0 # ── BBD202 worker ───────────────────────────────────────────────────────────── class BBD202Worker(QObject): """Manages ThorlabsServoDriver (MLS203-1) in a worker thread.""" connected = pyqtSignal() disconnected = pyqtSignal() connection_failed = pyqtSignal(str) position_updated = pyqtSignal(float, float) # x_mm, y_mm homed_status = pyqtSignal(bool, bool) # x_homed, y_homed error_occurred = pyqtSignal(str) def __init__(self): super().__init__() self.controller: ThorlabsServoDriver | None = None self._cmd_q: queue.Queue = queue.Queue() self._running = False self.is_connected = False self.scanning_active = False # pause polling during scan self._jog_step = BBD_DEFAULT_JOG_MM self._last_x: float | None = None self._last_y: float | None = None self._last_xh: bool | None = None self._last_yh: bool | None = None self._last_poll_t = 0.0 @pyqtSlot() def run(self): self._running = True while self._running: try: cmd = self._cmd_q.get(timeout=0.05) self._dispatch(cmd) except queue.Empty: pass if self.is_connected and self.controller and not self.scanning_active: self._poll() if self.controller: try: self.controller.disconnect() except Exception: pass def _dispatch(self, cmd: dict): t = cmd["type"] if t == "connect": self._do_connect(cmd["port"]) elif t == "disconnect": self._do_disconnect() elif t == "jog": self._do_jog(cmd["axis"], cmd["direction"]) elif t == "home_all": self._do_home_all() elif t == "enable_all": self._do_enable_all() elif t == "stop": self._running = False def _do_connect(self, port: str): try: self.controller = ThorlabsServoDriver() self.controller.serial_port = port self.controller.connect() self.controller.enable_axis(AXIS_X) self.controller.enable_axis(AXIS_Y) self.controller.start_polling(interval=0.2) time.sleep(0.3) self.controller.set_velocity_params(AXIS_X, max_velocity=BBD_JOG_SPEED_MM_S, acceleration=BBD_JOG_ACCEL_MM_S2) self.controller.set_velocity_params(AXIS_Y, max_velocity=BBD_JOG_SPEED_MM_S, acceleration=BBD_JOG_ACCEL_MM_S2) self.is_connected = True self.connected.emit() except Exception as e: self.connection_failed.emit(str(e)) def _do_disconnect(self): if self.controller: try: self.controller.disconnect() except Exception: pass self.controller = None self.is_connected = False self.disconnected.emit() def _do_jog(self, axis: str, direction: int): if not self.controller: return dest = AXIS_X if axis == "x" else AXIS_Y try: self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0) except TimeoutError: pass except Exception as e: self.error_occurred.emit(str(e)) def _do_home_all(self): if not self.controller: return def _home_task(): for dest, name in [(AXIS_X, "X"), (AXIS_Y, "Y")]: try: self.controller.home_axis(dest, timeout=120.0) except Exception as e: self.error_occurred.emit(f"Home {name} failed: {e}") Thread(target=_home_task, daemon=True).start() def _do_enable_all(self): if not self.controller: return self.controller.toggle_enabled_state(AXIS_X) self.controller.toggle_enabled_state(AXIS_Y) def _poll(self): now = time.time() if now - self._last_poll_t < 0.2: return self._last_poll_t = now try: x = self.controller.positions[0] y = self.controller.positions[1] if x != self._last_x or y != self._last_y: self._last_x = x self._last_y = y self.position_updated.emit(x, y) xh = self.controller.am_homed[0] yh = self.controller.am_homed[1] if xh != self._last_xh or yh != self._last_yh: self._last_xh = xh self._last_yh = yh self.homed_status.emit(xh, yh) except Exception: pass # ── Queue API ───────────────────────────────────────────────────────────── def queue_connect(self, port: str): self._cmd_q.put({"type": "connect", "port": port}) def queue_disconnect(self): self._cmd_q.put({"type": "disconnect"}) def queue_jog(self, axis: str, direction: int): self._cmd_q.put({"type": "jog", "axis": axis, "direction": direction}) def queue_home_all(self): self._cmd_q.put({"type": "home_all"}) def queue_enable_all(self): self._cmd_q.put({"type": "enable_all"}) def stop_worker(self): self._cmd_q.put({"type": "stop"}) # ── Oscilloscope worker ─────────────────────────────────────────────────────── class OscopeWorker(QObject): """Manages TektronixOscilloscopeBase in a worker thread.""" connected = pyqtSignal() disconnected = pyqtSignal() connection_failed = pyqtSignal(str) error_occurred = pyqtSignal(str) def __init__(self): super().__init__() self.scope: TektronixOscilloscopeBase | None = None self._cmd_q: queue.Queue = queue.Queue() self._running = False self.is_connected = False @pyqtSlot() def run(self): self._running = True while self._running: try: cmd = self._cmd_q.get(timeout=0.1) t = cmd["type"] if t == "connect": self._do_connect(cmd["ip"]) elif t == "disconnect": self._do_disconnect() elif t == "stop": self._running = False except queue.Empty: pass if self.scope: try: self.scope.disconnect() except Exception: pass def _do_connect(self, ip: str): try: self.scope = TektronixOscilloscopeBase(resource_name=ip, port=4000, timeout=10.0) self.scope.connect() self._configure_channels() self.is_connected = True self.connected.emit() except Exception as e: self.connection_failed.emit(str(e)) def _configure_channels(self): """Apply standard SRAS channel configuration after connecting.""" s = self.scope # Turn on all four channels for ch in (1, 2, 3, 4): s.write(f"SELect:CH{ch} ON") # ── CH1 — RF Acoustic Packet ────────────────────────────────────────── s.set_channel_label_name(1, "RF Acoustic Packet") s.set_channel_scale(1, 0.07) # 70 mV/div s.set_channel_position(1, 0.0) # 0 divs s.set_channel_termination(1, 50) # 50 Ohm s.set_channel_coupling(1, "DC") s.set_channel_bandwidth(1, 250E6) # 250Mhz Low-Pass # ── CH2 — Trigger Signal ────────────────────────────────────────────── s.set_channel_label_name(2, "Trigger Signal") s.set_channel_scale(2, 0.5) # 500 mV/div s.set_channel_position(2, -2.72) # -2.72 divs s.set_channel_termination(2, 1000000) # 50 Ohm s.set_channel_coupling(2, "DC") s.set_channel_bandwidth(2, 20E6) # 20 MHz # ── CH3 — Max Velocity Gate ─────────────────────────────────────────── s.set_channel_label_name(3, "Max Vel Gate") s.set_channel_scale(3, 1.0) # 1 V/div s.set_channel_position(3, -2.72) # -2.72 divs s.set_channel_termination(3, 1000000) # 1 MOhm s.set_channel_coupling(3, "DC") s.set_channel_bandwidth(3, 20E6) # 20 MHz # ── CH4 — Bias B ────────────────────────────────────────────────────── s.set_channel_label_name(4, "Bias - B") s.set_channel_scale(4, 0.1) # 100 mV/div s.set_channel_position(4, -2.72) # -2.72 divs s.set_channel_termination(4, 1000000) # 1 Mohm s.set_channel_coupling(4, "DC") s.set_channel_bandwidth(4, 20E6) # 20 MHz def _do_disconnect(self): if self.scope: try: self.scope.disconnect() except Exception: pass self.scope = None self.is_connected = False self.disconnected.emit() def queue_connect(self, ip: str): self._cmd_q.put({"type": "connect", "ip": ip}) def queue_disconnect(self): self._cmd_q.put({"type": "disconnect"}) def stop_worker(self): self._cmd_q.put({"type": "stop"}) # ── Helios laser worker ─────────────────────────────────────────────────────── class HeliosWorker(QObject): """Manages HeliosLaser in a worker thread using a command queue.""" connected = pyqtSignal() disconnected = pyqtSignal() connection_failed = pyqtSignal(str) enabled_updated = pyqtSignal(bool) current_updated = pyqtSignal(int) diode_temp_updated = pyqtSignal(float) pstage_temp_updated = pyqtSignal(float) qswitch_temp_updated = pyqtSignal(float) status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None) error_occurred = pyqtSignal(str) def __init__(self): super().__init__() self._laser: HeliosLaser | None = None self._cmd_q: queue.Queue = queue.Queue() self._running = False self.is_connected = False @pyqtSlot() def run(self): self._running = True while self._running: try: cmd = self._cmd_q.get(timeout=0.1) self._dispatch(cmd) except queue.Empty: pass if self._laser: try: self._laser.disconnect() except Exception: pass def _dispatch(self, cmd: dict): t = cmd["type"] if t == "connect": self._do_connect(cmd["port"]) elif t == "disconnect": self._do_disconnect() elif t == "set_enable": self._do_set_enable(cmd["enable"]) elif t == "set_current": self._do_set_current(cmd["current_ma"]) elif t == "poll_status": self._do_poll_status() elif t == "stop": self._running = False def _do_connect(self, port: str): try: self._laser = HeliosLaser(timeout=1.0) if not self._laser.connect(port): self._laser = None self.connection_failed.emit("Failed to open serial port") return self.is_connected = True self.connected.emit() self._do_poll_status() except Exception as e: self._laser = None self.connection_failed.emit(str(e)) def _do_disconnect(self): if self._laser: try: self._laser.set_laser_enable(False) except Exception: pass try: self._laser.disconnect() except Exception: pass self._laser = None self.is_connected = False self.disconnected.emit() def _do_set_enable(self, enable: bool): if not self._laser: return try: self._laser.set_laser_enable(enable) self.enabled_updated.emit(self._laser.is_laser_enabled()) except Exception as e: self.error_occurred.emit(str(e)) def _do_set_current(self, current_ma: int): if not self._laser: return try: self._laser.set_current_ma(current_ma) self.current_updated.emit(current_ma) except Exception as e: self.error_occurred.emit(str(e)) def _do_poll_status(self): if not self._laser or not self._laser.is_connected: return try: ler, lce, cce = self._laser.get_status_registers() self.status_registers_updated.emit(ler, lce, cce) except Exception: pass try: self.enabled_updated.emit(self._laser.is_laser_enabled()) except Exception: pass try: current = self._laser.get_current_ma() if current is not None: self.current_updated.emit(current) except Exception: pass try: t = self._laser.get_diode_temp_c() if t is not None: self.diode_temp_updated.emit(t) except Exception: pass try: t = self._laser.get_power_stage_temp_c() if t is not None: self.pstage_temp_updated.emit(t) except Exception: pass try: t = self._laser.get_qswitch_temp_c() if t is not None: self.qswitch_temp_updated.emit(t) except Exception: pass def queue_connect(self, port: str): self._cmd_q.put({"type": "connect", "port": port}) def queue_disconnect(self): self._cmd_q.put({"type": "disconnect"}) def queue_set_enable(self, enable: bool): self._cmd_q.put({"type": "set_enable", "enable": enable}) def queue_set_current(self, current_ma: int): self._cmd_q.put({"type": "set_current", "current_ma": current_ma}) def queue_poll_status(self): self._cmd_q.put({"type": "poll_status"}) def stop_worker(self): self._cmd_q.put({"type": "stop"}) # ── Camera window popup ─────────────────────────────────────────────────────── class CameraWindow(QWidget): """Camera display popup — auto-connects on show, auto-disconnects on close.""" closed = pyqtSignal() def __init__(self, parent: QWidget | None = None): super().__init__(parent, Qt.WindowType.Window) uic.loadUi(ROOT / "sc3-aui-camera.ui", self) self.setWindowTitle("uC480 Camera") self._camera: UC480Camera | None = None self._stream: CameraStreamThread | None = None # Banner warning when the camera shares a USB controller with serial # adapters — opening any of those ports (T3R, BBD202, …) collapses # the delivered frame rate to <2 fps (USB split-transaction # contention), which makes focusing impossible. self._bus_warn_label = QLabel(self) self._bus_warn_label.setWordWrap(True) self._bus_warn_label.setStyleSheet( "background: #7f1d1d; color: white; padding: 6px; font-weight: bold;") self._bus_warn_label.setVisible(False) self.verticalLayout.insertWidget(0, self._bus_warn_label) # Overlay a QLabel for frame rendering on top of the native display widget self._frame_label = QLabel(self.uc480_display_area) self._frame_label.setGeometry( 0, 0, self.uc480_display_area.width(), self.uc480_display_area.height(), ) self._frame_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._frame_label.setStyleSheet("background: black;") self.uc480_exposure_slider.setRange(1, 500) # 0.1 ms … 50 ms (×0.1) self.uc480_gain_slider.setRange(0, 100) self.uc480_exposure_slider.valueChanged.connect(self._set_exposure) self.uc480_gain_slider.valueChanged.connect(self._set_gain) self.uc480_start_btn.clicked.connect(self._start_stream) self.uc480_stop_btn.clicked.connect(self._stop_stream) self.uc480_close_window_btn.clicked.connect(self.close) self._update_controls() def showEvent(self, event): super().showEvent(event) self._connect_camera() self._start_stream() def closeEvent(self, event): self._stop_stream() self._disconnect_camera() super().closeEvent(event) self.closed.emit() def _connect_camera(self): if self._camera is not None: return try: self._camera = UC480Camera() self._camera.initialize() exp_ms = self._camera.get_exposure() self.uc480_exposure_slider.blockSignals(True) self.uc480_exposure_slider.setValue(max(1, round(exp_ms * 10))) self.uc480_exposure_slider.blockSignals(False) except Exception as e: self._camera = None self._frame_label.setText(f"Camera error:\n{e}") self._refresh_bus_warning() self._update_controls() def _refresh_bus_warning(self): conflicts = find_camera_bus_conflicts() if conflicts: devs = ", ".join( f"/dev/{name} ({product})" if product else f"/dev/{name}" for name, _, product in conflicts ) self._bus_warn_label.setText( f"⚠ Camera shares a USB controller with: {devs}. Opening any of " f"these serial ports (T3R panel, BBD202, …) will collapse the " f"frame rate. Fix: plug the camera into a USB-3 (xHCI) port." ) self._bus_warn_label.setVisible(bool(conflicts)) def _disconnect_camera(self): if self._camera: try: self._camera.cleanup() except Exception: pass self._camera = None self._update_controls() def _start_stream(self): if self._stream is not None or self._camera is None: return self._stream = CameraStreamThread(self._camera) self._stream.frame_ready.connect(self._on_frame) self._stream.start() self._update_controls() def _stop_stream(self): if self._stream: self._stream.stop() self._stream = None self._update_controls() def _on_frame(self, image: QImage): px = QPixmap.fromImage(image).scaled( self._frame_label.width(), self._frame_label.height(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation, ) self._frame_label.setPixmap(px) def _set_exposure(self, val: int): if self._camera: try: self._camera.set_exposure(val * 0.1) except Exception: pass def _set_gain(self, val: int): if self._camera: try: self._camera.set_gain(val) except Exception: pass def _update_controls(self): camera_ok = self._camera is not None streaming = self._stream is not None self.uc480_start_btn.setEnabled(camera_ok and not streaming) self.uc480_stop_btn.setEnabled(streaming) self.uc480_exposure_slider.setEnabled(camera_ok) self.uc480_gain_slider.setEnabled(camera_ok) # ── Helios laser panel ──────────────────────────────────────────────────────── class HeliosWindow(QWidget): """Helios laser control panel — shown/hidden with a toggle in the main window.""" closed = pyqtSignal() def __init__(self, worker: HeliosWorker, parent: QWidget | None = None): super().__init__(parent, Qt.WindowType.Window) uic.loadUi(ROOT / "sc3-aui-helios.ui", self) self.setWindowTitle("Helios Laser") self._worker = worker self._poll_timer = QTimer(self) self._poll_timer.setInterval(1000) self._poll_timer.timeout.connect(self._worker.queue_poll_status) self._wire_signals() self._update_controls(False) self._set_emission_indicator(False) def closeEvent(self, event): self._poll_timer.stop() if self._worker.is_connected: self._worker.queue_disconnect() super().closeEvent(event) self.closed.emit() def _wire_signals(self): self.helios_connect_toggle.toggled.connect(self._on_connect_toggle) self._worker.connected.connect(self._on_connected) self._worker.disconnected.connect(self._on_disconnected) self._worker.connection_failed.connect(self._on_connection_failed) self.helios_enable_btn.toggled.connect(self._on_enable_toggle) self._worker.enabled_updated.connect(self._on_enabled_updated) self.helios_set_current_btn.clicked.connect(self._on_set_current) self._worker.current_updated.connect(self._on_current_updated) self._worker.diode_temp_updated.connect( lambda t: self.helios_temp_diode_label.setText(f"{t:.1f} °C") ) self._worker.pstage_temp_updated.connect( lambda t: self.helios_temp_pstage_label.setText(f"{t:.1f} °C") ) self._worker.qswitch_temp_updated.connect( lambda t: self.helios_temp_qswitch_label.setText(f"{t:.1f} °C") ) self._worker.status_registers_updated.connect(self._on_status_registers) self._worker.error_occurred.connect( lambda m: self.helios_status_label.setText(m) ) def _on_connect_toggle(self, checked: bool): if checked: self.helios_connect_toggle.setText("Connecting…") self.helios_connect_toggle.setEnabled(False) port = self.helios_port_edit.text().strip() DEFAULTS.helios_port = port DEFAULTS.save() self._worker.queue_connect(port) else: self._poll_timer.stop() self._worker.queue_disconnect() def _on_connected(self): self.helios_connect_toggle.setEnabled(True) self.helios_connect_toggle.setText("Disconnect") self.helios_status_label.setText("Connected") self._update_controls(True) self._poll_timer.start() def _on_disconnected(self): self._poll_timer.stop() self.helios_connect_toggle.blockSignals(True) self.helios_connect_toggle.setChecked(False) self.helios_connect_toggle.setText("Connect") self.helios_connect_toggle.setEnabled(True) self.helios_connect_toggle.blockSignals(False) self.helios_status_label.setText("Disconnected") self._update_controls(False) def _on_connection_failed(self, msg: str): self.helios_connect_toggle.blockSignals(True) self.helios_connect_toggle.setChecked(False) self.helios_connect_toggle.setText("Connect") self.helios_connect_toggle.setEnabled(True) self.helios_connect_toggle.blockSignals(False) self.helios_status_label.setText(f"Failed: {msg}") self._update_controls(False) def _set_emission_indicator(self, enabled: bool): if enabled: self.helios_emission_indicator.setStyleSheet( "background-color: #ff2222; border-radius: 10px;" ) else: self.helios_emission_indicator.setStyleSheet( "background-color: #444444; border-radius: 10px;" ) def _on_enable_toggle(self, checked: bool): self.helios_enable_btn.setText("Disable Laser" if checked else "Enable Laser") self._worker.queue_set_enable(checked) def _on_enabled_updated(self, enabled: bool): self.helios_enable_btn.blockSignals(True) self.helios_enable_btn.setChecked(enabled) self.helios_enable_btn.setText("Disable Laser" if enabled else "Enable Laser") self.helios_enable_btn.blockSignals(False) self._set_emission_indicator(enabled) def _on_set_current(self): self._worker.queue_set_current(self.helios_current_spin.value()) def _on_current_updated(self, current_ma: int): self.helios_current_spin.blockSignals(True) self.helios_current_spin.setValue(current_ma) self.helios_current_spin.blockSignals(False) def _reset_temperatures(self): self.helios_temp_diode_label.setText("--- °C") self.helios_temp_pstage_label.setText("--- °C") self.helios_temp_qswitch_label.setText("--- °C") def _on_status_registers(self, ler, lce, cce): self.helios_ler_label.setText(f"0x{ler:04X}" if ler is not None else "---") self.helios_lce_label.setText(f"0x{lce:04X}" if lce is not None else "---") self.helios_cce_label.setText(f"0x{cce:04X}" if cce is not None else "---") def _update_controls(self, connected: bool): self.helios_enable_btn.setEnabled(connected) self.helios_current_spin.setEnabled(connected) self.helios_set_current_btn.setEnabled(connected) if not connected: self.helios_enable_btn.blockSignals(True) self.helios_enable_btn.setChecked(False) self.helios_enable_btn.setText("Enable Laser") self.helios_enable_btn.blockSignals(False) self._set_emission_indicator(False) self._reset_temperatures() self.helios_ler_label.setText("---") self.helios_lce_label.setText("---") self.helios_cce_label.setText("---") # ── Resume angle picker ─────────────────────────────────────────────────────── class ResumeAngleDialog(QDialog): """Lets the operator pick which angles of a .sras file to (re)acquire. Angles whose data is missing or truncated on disk are pre-checked; any other angle can also be checked to force it to be redone (e.g. a completed angle that's known to be bad). """ def __init__(self, statuses, parent: QWidget | None = None): super().__init__(parent) self.setWindowTitle("Select Angles to Rescan") self.resize(480, 420) layout = QVBoxLayout(self) layout.addWidget(QLabel( "Check the angle(s) to (re)acquire. Missing/truncated angles are " "pre-checked; check any other angle to force it to be redone." )) self.list_widget = QListWidget(self) for s in statuses: label = ( f"Angle {s.index + 1}/{len(statuses)} — {s.angle_deg:.2f}° — " f"{s.n_rows_available}/{s.n_rows} rows — {s.status}" ) item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, s.index) item.setCheckState( Qt.CheckState.Unchecked if s.complete else Qt.CheckState.Checked ) self.list_widget.addItem(item) layout.addWidget(self.list_widget) btn_row = QHBoxLayout() select_all_btn = QPushButton("Select All") select_none_btn = QPushButton("Select None") select_all_btn.clicked.connect(lambda: self._set_all_checked(True)) select_none_btn.clicked.connect(lambda: self._set_all_checked(False)) btn_row.addWidget(select_all_btn) btn_row.addWidget(select_none_btn) btn_row.addStretch(1) layout.addLayout(btn_row) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def _set_all_checked(self, checked: bool): state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked for i in range(self.list_widget.count()): self.list_widget.item(i).setCheckState(state) def selected_indices(self) -> list[int]: out = [] for i in range(self.list_widget.count()): item = self.list_widget.item(i) if item.checkState() == Qt.CheckState.Checked: out.append(item.data(Qt.ItemDataRole.UserRole)) return sorted(out) # ── Scan progress popup ─────────────────────────────────────────────────────── class ScanProgressWindow(QWidget): abort_requested = pyqtSignal() pause_toggled = pyqtSignal(bool) # True = pause requested, False = resume def __init__(self, parent: QWidget | None = None): super().__init__(parent, Qt.WindowType.Window) uic.loadUi(ROOT / "sc3-aui-scanprogress.ui", self) self.setWindowTitle("Scan Progress") self.abort_btn.clicked.connect(self.abort_requested.emit) self.pause_btn.toggled.connect(self._on_pause_btn) self.bias_image_widget = DCBiasImageWidget() insert_pos = self.verticalLayout.count() - 2 # above PAUSE + ABORT self.verticalLayout.insertWidget(insert_pos, QLabel("DC Bias Preview")) self.verticalLayout.insertWidget(insert_pos + 1, self.bias_image_widget) self.verticalLayout.setStretch(insert_pos + 1, 1) self._eta = EtaEstimator() def _on_pause_btn(self, checked: bool): # Pause takes effect at the next row boundary; show the intermediate # state until the worker confirms via on_worker_paused(). self.pause_btn.setText("PAUSING…" if checked else "PAUSE") self.pause_toggled.emit(checked) def on_worker_paused(self, paused: bool): if paused and self.pause_btn.isChecked(): self.pause_btn.setText("RESUME") def reset_pause_btn(self): self.pause_btn.blockSignals(True) self.pause_btn.setChecked(False) self.pause_btn.setText("PAUSE") self.pause_btn.blockSignals(False) def on_row_started(self, row: int, n_rows: int, angle_idx: int, n_angles: int): self._eta.row_started() def update_progress(self, row: int, n_rows: int, angle_idx: int, n_angles: int): n_rows = max(1, n_rows) n_angles = max(1, n_angles) self.current_scan_current_row_indicator.setText(f"Row {row} of {n_rows}") self.overall_scan_current_angle_indicator.setText(f"Angle {angle_idx} of {n_angles}") if row == 0: self._eta.reset() self.current_scan_progbar.setMaximum(n_rows) self.current_scan_progbar.setValue(0) self.current_scan_progbar.setFormat("Row %v/%m") else: self._eta.row_finished(angle_idx) self.current_scan_progbar.setMaximum(n_rows) self.current_scan_progbar.setValue(row) rows_left = n_rows - row eta_secs = self._eta.eta_secs(rows_left) if eta_secs is not None: self.current_scan_progbar.setFormat( f"Row %v/%m — ETA: {format_eta(eta_secs)}") elif rows_left == 0: self.current_scan_progbar.setFormat("Row %v/%m — Done") else: self.current_scan_progbar.setFormat("Row %v/%m") pct_ang = round(angle_idx / n_angles * 100) self.overall_scan_progbar.setValue(pct_ang) def update_dc_bias_plot(self, row: int, values): self.bias_image_widget.add_row(row, values) # ── Scan worker ─────────────────────────────────────────────────────────────── class ScanWorker(QObject): """Runs the full SRAS scan sequence in a background thread. Accesses hardware drivers directly (not through worker queues) so it can make blocking calls. BBD202Worker.scanning_active is set to True for the duration to suppress position polling in the BBD worker thread. """ started = pyqtSignal() completed = pyqtSignal() dc_bias_updated = pyqtSignal(int, object) # row index, list[float] per-frame DC means failed = pyqtSignal(str) row_started = pyqtSignal(int, int, int, int) # row, n_rows, angle_idx, n_angles row_done = pyqtSignal(int, int, int, int) # row, n_rows, angle_idx, n_angles status_msg = pyqtSignal(str) user_prompt = pyqtSignal(str, str) # title, message paused_changed = pyqtSignal(bool) # True while paused at a row boundary def __init__(self, bbd: BBD202Worker, t3r: T3RDriver | None, oscope: OscopeWorker, plan: ScanPlan, prefix: str, save_dir: str, resume_info: dict | None = None): super().__init__() self._bbd = bbd self._t3r = t3r self._oscope = oscope self._plan = plan self._prefix = prefix self._save_dir = save_dir self._resume_info = resume_info self._abort = False self._prompt_event = threading.Event() self._resume_event = threading.Event() self._resume_event.set() # set = running, cleared = pause requested def abort(self): self._abort = True self._resume_event.set() # unblock a paused scan so it can exit def pause(self): """Request a pause; takes effect at the next row boundary.""" self._resume_event.clear() def resume(self): self._resume_event.set() def _pause_point(self): """Block here (between rows, hardware idle) while a pause is requested.""" if self._resume_event.is_set() or self._abort: return self.status_msg.emit( "Scan paused — lasers may be switched off. " "Turn lasers back on before resuming." ) self.paused_changed.emit(True) while not self._resume_event.wait(0.2): if self._abort: break self.paused_changed.emit(False) if not self._abort: self.status_msg.emit("Scan resumed.") def acknowledge_prompt(self): """Called from UI thread when user clicks OK on a prompt dialog.""" self._prompt_event.set() def _request_user_prompt(self, title: str, message: str): """Emit a prompt signal and block until the UI thread acknowledges it.""" self._prompt_event.clear() self.user_prompt.emit(title, message) self._prompt_event.wait() @pyqtSlot() def run(self): try: self._run_scan() except Exception as e: import traceback traceback.print_exc() self.failed.emit(str(e)) def _run_scan(self): plan = self._plan per_angle = plan.per_angle n_angles = plan.n_angles save_dir = Path(self._save_dir) # The actual X move starts one ramp-length + buffer before x_start and # ends one ramp-length + buffer after x_start + x_delta, so the stage # is at full velocity across the whole data window. _x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM validate_plan(plan, SCAN_RAMP_MM, SCAN_RAMP_BUFFER_MM) geometry_summary = ", ".join( f"{pa.angle_deg:.1f}°: {pa.n_rows} row(s) × {pa.n_frames} pts/row" for pa in per_angle ) self.status_msg.emit( f"Scan geometry: {n_angles} angle(s), {plan.total_rows} row(s) total " f"(per-angle bounding box) | save → {save_dir}\n{geometry_summary}" ) self.started.emit() # ── Validate hardware ───────────────────────────────────────────────── ctrl = self._bbd.controller scope = self._oscope.scope if ctrl is None: raise RuntimeError("BBD202 not connected") if scope is None: raise RuntimeError("Oscilloscope not connected") if n_angles > 1 and (self._t3r is None or not self._t3r.is_open): raise RuntimeError( f"NumAngles={n_angles} requires the T3R rotation stage (GR-axis), " "but it is not connected. Connect T3R from the T3R panel before " "starting a multi-angle scan, or set NumAngles to 1." ) # ── Configure GR rotation axis ──────────────────────────────────────── # steps_for_angle() assumes GR_MICROSTEPS, so the device must be set to # match — never rely on whatever the T3R panel (or firmware default) # last left it at. Also set drive current and energise the channel. if self._t3r is not None and self._t3r.is_open: self.status_msg.emit( f"Configuring GR axis: {GR_MICROSTEPS} µsteps, " f"{GR_RUN_CURRENT_MA}/{GR_HOLD_CURRENT_MA} mA run/hold …" ) gr_ch = self._t3r.GR_AXIS_CH self._t3r.set_microstep(gr_ch, GR_MICROSTEPS) self._t3r.set_current(gr_ch, GR_RUN_CURRENT_MA, GR_HOLD_CURRENT_MA, GR_IHOLD_DELAY) self._t3r.enable(gr_ch) time.sleep(0.2) # ── Prepare stage ───────────────────────────────────────────────────── self.status_msg.emit("Enabling stage axes …") if not ctrl.am_enabled[0]: ctrl.enable_axis(AXIS_X) if not ctrl.am_enabled[1]: ctrl.enable_axis(AXIS_Y) time.sleep(0.2) if not ctrl.am_homed[0] or not ctrl.am_homed[1]: self.status_msg.emit("Homing stage (may take up to 2 min) …") if not ctrl.am_homed[0]: ctrl.home_axis(AXIS_X, timeout=120.0) if not ctrl.am_homed[1]: ctrl.home_axis(AXIS_Y, timeout=120.0) self.status_msg.emit("Setting scan velocity …") ctrl.set_velocity_params(AXIS_X, max_velocity=SCAN_VELOCITY_MM_S, acceleration=SCAN_ACCEL_MM_S2) ctrl.set_velocity_params(AXIS_Y, max_velocity=SCAN_VELOCITY_MM_S, acceleration=SCAN_ACCEL_MM_S2) # X trigger: logic-high output when stage is at maximum velocity ctrl.set_trigger_trigout_maxv(AXIS_X) # ── Configure oscilloscope ──────────────────────────────────────────── self.status_msg.emit("Configuring oscilloscope …") # Edge trigger: rising edge of CH2 (laser pulse) at 1.0 V. scope.write("TRIGger:A:TYPe EDGE") scope.set_trigger_source(2) scope.set_trigger_slope("RISE") scope.set_trigger_level(2, SCOPE_TRIG_LEVEL_V) scope.set_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep) scope.set_acquire_mode("SAMPLE") scope.set_fastframe_state(False) # start non-FF for background capture scope.set_sample_rate(SCOPE_SAMPLE_RATE) scope.write("HORizontal:POSition 30") # 10 % trigger offset time.sleep(0.3) samples_per_frame = scope.get_record_length() resume_info = self._resume_info if resume_info is None: # ── Snapshot WFMOutpre for each channel (captures YMULT/YOFF/YZERO) ── preambles = [] for ch in SCAN_CHANNELS: scope.set_data_source(ch) preambles.append(scope.query_wfmoutpre()) # ── Background subtraction capture ────────────────────────────────── # Prompt user to ensure Helios is ON and Genesis is OFF. self._request_user_prompt( "Background Capture", "Please ensure the Helios laser is ON and the Genesis laser is OFF,\n" "then click OK to capture the background waveform." ) if self._abort: self.failed.emit("Scan aborted by user.") return # Capture a single averaged CH1 frame (1024 waveforms averaged). self.status_msg.emit("Capturing background waveform (1024-average) …") scope.set_acquire_mode("AVERAGE") scope.write("ACQuire:NUMAVg 1024") scope.write("ACQuire:STOPAfter SEQuence") # auto-stop after all 1024 averages scope.set_data_source(1) scope.write("ACQuire:STATE RUN") # Poll until the scope finishes all 1024 averages and auto-stops. # Timeout: 1024 averages at 2 kHz (worst case) = ~0.5 s; allow 60 s. _bg_deadline = time.time() + 60.0 while time.time() < _bg_deadline: if self._abort: break if scope.query("ACQuire:STATE?").strip() == "0": break time.sleep(0.25) else: scope.write("ACQuire:STATE STOP") self.status_msg.emit("Warning: background average timed out; stopping early.") time.sleep(0.1) background_waveform = scope.transfer_curve() # Prompt user to turn Genesis back on before the actual scan. self._request_user_prompt( "Begin Scanning", "Background captured successfully.\n\n" "Please ensure the Genesis laser is back ON,\n" "then click OK to begin scanning." ) if self._abort: self.failed.emit("Scan aborted by user.") return else: # Resuming an existing file: the background waveform and channel # preambles it already contains are reused as-is (the format has # no way to replace them without rewriting the whole file), so # background capture is skipped entirely. Sanity-check that this # scope is still producing the same record length the file was # started with — a mismatch would silently corrupt the ragged # per-row byte layout on append. if samples_per_frame != resume_info["samples_per_frame"]: raise RuntimeError( f"Oscilloscope record length ({samples_per_frame} samples/frame) " f"does not match the {resume_info['samples_per_frame']} samples/frame " f"this scan file was started with — cannot safely resume." ) target_angles = ", ".join(str(t["ai"] + 1) for t in resume_info["targets"]) self._request_user_prompt( "Resume Scan", f"Resuming {resume_info['path'].name} — will (re)acquire " f"angle(s) {target_angles} of {n_angles}.\n\n" "Please re-home the GR axis to 0° before continuing — the scan " "will rotate it directly from angle to angle before scanning resumes.\n\n" "Please ensure the Genesis laser is ON,\n" "then click OK to continue scanning." ) if self._abort: self.failed.emit("Scan aborted by user.") return # Restore SAMPLE mode and FastFrame for the actual scan. FastFrame # count (points/row) is set per-angle in the scan loop below, since # it depends on that angle's bounding-box X extent. scope.write("ACQuire:STOPAfter RUNSTop") scope.set_acquire_mode("SAMPLE") scope.set_fastframe_state(True) # Restore logic-AND trigger (CH2 HIGH AND CH3 HIGH) for the scan loop: # CH3 is the BBD202 TRIGOUT_MAXV gate, so frames only accumulate while # the stage is at full scan velocity. scope.write("TRIGger:A:TYPe LOGIc") scope.write("TRIGger:A:LOGIc:FUNCtion AND") scope.set_trigger_level(2, SCOPE_TRIG_LEVEL_V) scope.set_trigger_level(3, SCOPE_TRIG_LEVEL_V) scope.write("TRIGger:A:LOGICPattern:CH2 HIGH") scope.write("TRIGger:A:LOGICPattern:CH3 HIGH") time.sleep(0.2) # ── Open output file (entire scan in one .sras) ───────────────────────── if resume_info is not None: targets_by_ai = {t["ai"]: t for t in resume_info["targets"]} scan_file = open(resume_info["path"], "r+b") self.status_msg.emit( f"Resuming {resume_info['path'].name} — " f"{len(targets_by_ai)} angle(s) to (re)acquire …" ) else: targets_by_ai = None fname = save_dir / f"{self._prefix}.sras" scan_file = create_scan_file( fname, plan, samples_per_frame, SCOPE_SAMPLE_RATE, preambles, background_waveform, ) # ── Scan loop ───────────────────────────────────────────────────────── self._bbd.scanning_active = True # Both fresh and resumed scans assume the GR axis starts at home (0°) # — the resume prompt instructs the operator to re-home it before # continuing — so the first move always rotates directly from 0° # to the starting angle. prev_gr_deg = 0.0 try: for ai, pa in enumerate(per_angle): if targets_by_ai is not None and ai not in targets_by_ai: continue # not selected for (re)acquisition self._pause_point() if self._abort: break angle = pa.angle_deg a_x_start = pa.x_start a_x_delta = pa.x_delta a_n_rows = pa.n_rows a_n_frames = pa.n_frames y_positions = pa.y_positions if targets_by_ai is not None: # Interior angles may already have valid data on either # side of them, so seek to this angle's fixed offset # rather than relying on the file's current position. scan_file.seek(targets_by_ai[ai]["data_offset"]) # ── Rotate GR ───────────────────────────────────────────────── if self._t3r is not None and self._t3r.is_open: delta_deg = angle - prev_gr_deg if abs(delta_deg) > 0.001: steps = self._t3r.steps_for_angle(delta_deg, GR_MICROSTEPS) # Trapezoidal move time: cruise + accel/decel ramps est_secs = (abs(steps) / GR_MOVE_VELOCITY + GR_MOVE_VELOCITY / GR_MOVE_ACCEL) self.status_msg.emit( f"Rotating GR to {angle:.1f}° (Δ{delta_deg:+.1f}°, " f"≈{est_secs:.1f} s) …" ) self._t3r.rotate_stage(delta_deg, GR_MICROSTEPS, GR_MOVE_VELOCITY, GR_MOVE_ACCEL) time.sleep(est_secs + 0.5) prev_gr_deg = angle # This angle's bounding box gives it its own points/row count, # so the scope's FastFrame count must be re-armed per angle. scope.set_fastframe_count(a_n_frames) # ── Row loop ────────────────────────────────────────────────── for ri, y_pos in enumerate(y_positions): self._pause_point() if self._abort: break self.row_started.emit(ri + 1, a_n_rows, ai + 1, n_angles) self.status_msg.emit( f"Angle {ai+1}/{n_angles} Row {ri+1}/{a_n_rows} " f"(Y={y_pos:.3f} mm)" ) # Position stage one ramp-length + buffer before the data # window so the stage is at full velocity before x_start. ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0) ctrl.move_axis_absolute(AXIS_X, a_x_start - _x_ramp_total, timeout=30.0) # Arm oscilloscope — trigger is gated with TRIGOUT_MAXV so # frames only accumulate once the stage reaches full velocity scope.write("ACQuire:STATE RUN") time.sleep(0.05) # Execute scan move: data window + ramp + buffer run-off so # the stage does not begin decelerating before the last point. x_end = a_x_start + a_x_delta + _x_ramp_total ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0) # Brief settle: wait for trailing frames then stop acquisition time.sleep(0.2) scope.write("ACQuire:STATE STOP") # Stream all channels from oscilloscope and append to file. # CH3 is the max-vel gate signal — no useful waveform data, # so write zeroed frames to keep the file format intact. for ch in SCAN_CHANNELS: if ch == 3: self.status_msg.emit("Writing zeroed CH3 frames …") zero_frame = bytes(samples_per_frame) n_frames_acquired = int(scope.query("ACQuire:NUMFRAMESACQuired?")) for _ in range(n_frames_acquired): scan_file.write(zero_frame) elif ch == 4: self.status_msg.emit(f"Fetching CH{ch} data …") scope.set_data_source(ch) waveforms: list[bytes] = scope.transfer_fastframe(parse=False) if waveforms: frame_avgs = [] for w in waveforms: n = len(w) frame_avgs.append( sum(struct.unpack(f"{n}b", w)) / n if n > 0 else 0.0 ) self.dc_bias_updated.emit(ri + 1, frame_avgs) for w in waveforms: scan_file.write(w) else: self.status_msg.emit(f"Fetching CH{ch} data …") scope.set_data_source(ch) waveforms: list[bytes] = scope.transfer_fastframe(parse=False) for w in waveforms: scan_file.write(w) self.row_done.emit(ri + 1, a_n_rows, ai + 1, n_angles) finally: scan_file.close() self._bbd.scanning_active = False # Return GR axis to home (0°) regardless of abort or error if self._t3r is not None and self._t3r.is_open and abs(prev_gr_deg) > 0.001: return_deg = -prev_gr_deg est_secs = (abs(self._t3r.steps_for_angle(return_deg, GR_MICROSTEPS)) / GR_MOVE_VELOCITY + GR_MOVE_VELOCITY / GR_MOVE_ACCEL) self.status_msg.emit( f"Returning GR to home ({return_deg:+.1f}°, ≈{est_secs:.1f} s) …" ) self._t3r.rotate_stage(return_deg, GR_MICROSTEPS, GR_MOVE_VELOCITY, GR_MOVE_ACCEL) time.sleep(est_secs + 0.5) if self._abort: self.failed.emit("Scan aborted by user.") else: self.status_msg.emit("Scan complete.") self.completed.emit() # ── Main window ─────────────────────────────────────────────────────────────── class MainWindow(QMainWindow): def __init__(self): super().__init__() uic.loadUi(ROOT / "sc3-aui-main.ui", self) self.setWindowTitle("Scanengine-3 AUI") # ── T3R driver (owns serial + reader QThread internally) ───────────── self._t3r_driver = T3RDriver(self) self._t3r_panel: T3RControlPanel | None = None # ── Worker threads ──────────────────────────────────────────────────── self._bbd_thread = QThread(self) self._bbd_worker = BBD202Worker() self._bbd_worker.moveToThread(self._bbd_thread) self._bbd_thread.started.connect(self._bbd_worker.run) self._oscope_thread = QThread(self) self._oscope_worker = OscopeWorker() self._oscope_worker.moveToThread(self._oscope_thread) self._oscope_thread.started.connect(self._oscope_worker.run) self._helios_thread = QThread(self) self._helios_worker = HeliosWorker() self._helios_worker.moveToThread(self._helios_thread) self._helios_thread.started.connect(self._helios_worker.run) # ── Popup windows ───────────────────────────────────────────────────── self._camera_win = CameraWindow() self._helios_win = HeliosWindow(self._helios_worker) self._scan_progress = ScanProgressWindow() self._scan_worker: ScanWorker | None = None self._scan_thread: QThread | None = None self._bbd_active_jog: tuple[str, int] | None = None # (axis, direction) self._bbd_jog_timer = QTimer(self) self._bbd_jog_timer.setInterval(200) self._bbd_jog_timer.timeout.connect(self._bbd_jog_tick) self._init_menu_bar() self._init_ui_fields() self._wire_signals() self._bbd_thread.start() self._oscope_thread.start() self._helios_thread.start() # ── Menu bar ─────────────────────────────────────────────────────────────── def _init_menu_bar(self): scan_menu = self.menuBar().addMenu("&Scan") self.resume_scan_action = scan_menu.addAction("&Resume Scan…") self.resume_scan_action.triggered.connect(self._on_resume_scan_action) # ── UI initialisation ───────────────────────────────────────────────────── def _init_ui_fields(self): self.t3r_comport_edit.setText(DEFAULTS.t3r_port) self.bbd202_comport_edit.setText(DEFAULTS.bbd_port) self.oscope_ip_edit.setText(DEFAULTS.oscope_ip) self._helios_win.helios_port_edit.setText(DEFAULTS.helios_port) self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM)) self.x_start_edit.setText("10.000") self.y_start_edit.setText("10.000") self.x_delta_edit.setText("80.000") self.y_delta_edit.setText("50.000") self.num_angles_edit.setText("1") self.row_spacing_edit.setText("0.250") self.scan_prefix_edit.setText("scan") self.scan_save_dir_edit.setText(DEFAULTS.save_dir) # Hide the old T3R manual controls; the connect toggle becomes the panel button. for w in ( self.t3r_enable_t1_btn, self.t3r_enable_t2_btn, self.t3r_enable_t3_btn, self.t3r_enable_gr_btn, self.jog_t1_up_btn, self.jog_t1_down_btn, self.jog_t2_up_btn, self.jog_t2_down_btn, self.jog_t3_up_btn, self.jog_t3_down_btn, self.jog_gr_ccw_btn, self.jog_gr_cw_btn, self.t3r_set_current_t1_btn, self.t3r_set_current_t2_btn, self.t3r_set_current_t3_btn, self.t3r_set_current_gr_btn, self.t3r_current_t1_spin, self.t3r_current_t2_spin, self.t3r_current_t3_spin, self.t3r_current_gr_spin, self.t3r_jog_speed_edit, ): w.setVisible(False) self.t3r_connect_toggle.setCheckable(True) self.t3r_connect_toggle.setText("Show T3R Panel") self._set_bbd_controls_enabled(False) # ── Signal wiring ───────────────────────────────────────────────────────── def _wire_signals(self): # T3R — toggle button opens/closes the T3RControlPanel self.t3r_connect_toggle.toggled.connect(self._on_t3r_panel_toggle) # BBD202 self.bbd202_connect_toggle.toggled.connect(self._on_bbd_toggle) self._bbd_worker.connected.connect(self._on_bbd_connected) self._bbd_worker.disconnected.connect(self._on_bbd_disconnected) self._bbd_worker.connection_failed.connect(self._on_bbd_failed) self._bbd_worker.position_updated.connect(self._on_bbd_position) self._bbd_worker.error_occurred.connect( lambda m: print(f"[BBD] {m}") ) self.bbd_home_all_btn.clicked.connect(lambda: self._bbd_worker.queue_home_all()) self.bbd_enable_all_btn.clicked.connect(lambda: self._bbd_worker.queue_enable_all()) self.bbd_jog_x_pos_btn.pressed.connect(lambda: self._start_bbd_jog("x", 1)) self.bbd_jog_x_pos_btn.released.connect(self._stop_bbd_jog) self.bbd_jog_x_neg_btn.pressed.connect(lambda: self._start_bbd_jog("x", -1)) self.bbd_jog_x_neg_btn.released.connect(self._stop_bbd_jog) self.bbd_jog_y_pos_btn.pressed.connect(lambda: self._start_bbd_jog("y", 1)) self.bbd_jog_y_pos_btn.released.connect(self._stop_bbd_jog) self.bbd_jog_y_neg_btn.pressed.connect(lambda: self._start_bbd_jog("y", -1)) self.bbd_jog_y_neg_btn.released.connect(self._stop_bbd_jog) self.bbd_set_current_start_btn.clicked.connect(self._on_set_start_from_pos) self.bbd_set_delta_current_btn.clicked.connect(self._on_calc_delta) # Oscilloscope self.oscope_connect_toggle.toggled.connect(self._on_oscope_toggle) self._oscope_worker.connected.connect(self._on_oscope_connected) self._oscope_worker.disconnected.connect(self._on_oscope_disconnected) self._oscope_worker.connection_failed.connect(self._on_oscope_failed) # Persist port fields to aui_defaults.json on change self.t3r_comport_edit.editingFinished.connect(self._persist_defaults) self.bbd202_comport_edit.editingFinished.connect(self._persist_defaults) self.oscope_ip_edit.editingFinished.connect(self._persist_defaults) # Camera self.show_camera_toggle.toggled.connect(self._on_camera_toggle) self._camera_win.closed.connect( lambda: self._set_toggle(self.show_camera_toggle, False, "Show Camera Window") ) # Helios self.show_helios_toggle.toggled.connect(self._on_helios_toggle) self._helios_win.closed.connect( lambda: self._set_toggle(self.show_helios_toggle, False, "Show Helios Panel") ) self._helios_win.helios_close_btn.clicked.connect( lambda: self.show_helios_toggle.setChecked(False) ) # Scan self.start_scan_btn.clicked.connect(self._on_start_scan) self.save_dir_browse_btn.clicked.connect(self._on_browse_save_dir) self._scan_progress.abort_requested.connect(self._on_abort_scan) self._scan_progress.pause_toggled.connect(self._on_pause_scan) # ── T3R panel toggle ────────────────────────────────────────────────────── def _on_t3r_panel_toggle(self, checked: bool): if checked: self._show_t3r_panel() elif self._t3r_panel is not None: self._t3r_panel.hide() self.t3r_connect_toggle.setText("Show T3R Panel") def _show_t3r_panel(self): if self._t3r_panel is None: self._t3r_panel = T3RControlPanel(self._t3r_driver, self) # Pre-fill port from the field that's still shown in the main UI port = self.t3r_comport_edit.text().strip() if port: idx = self._t3r_panel.port_combo.findData(port) if idx < 0: self._t3r_panel.port_combo.insertItem(0, port, port) idx = 0 self._t3r_panel.port_combo.setCurrentIndex(idx) self._t3r_panel.finished.connect(self._on_t3r_panel_closed) self._t3r_panel.show() self._t3r_panel.raise_() self.t3r_connect_toggle.setText("Hide T3R Panel") def _on_t3r_panel_closed(self): self._set_toggle(self.t3r_connect_toggle, False, "Show T3R Panel") # ── BBD202 slots ────────────────────────────────────────────────────────── def _on_bbd_toggle(self, checked: bool): if checked: self.bbd202_connect_toggle.setText("Connecting…") self.bbd202_connect_toggle.setEnabled(False) self._bbd_worker.queue_connect(self.bbd202_comport_edit.text().strip()) else: self._bbd_worker.queue_disconnect() def _on_bbd_connected(self): self.bbd202_connect_toggle.setEnabled(True) self.bbd202_connect_toggle.setText("Disconnect") self._set_bbd_controls_enabled(True) def _on_bbd_disconnected(self): self._set_toggle(self.bbd202_connect_toggle, False, "Connect") self._set_bbd_controls_enabled(False) self.bbd_current_x_position_indicator.setText("---.-") self.bbd_current_y_position_indicator.setText("---.-") def _on_bbd_failed(self, msg: str): self._set_toggle(self.bbd202_connect_toggle, False, "Connect") self.bbd202_connect_toggle.setEnabled(True) QMessageBox.warning(self, "BBD202 Connection Failed", msg) def _on_bbd_position(self, x: float, y: float): self.bbd_current_x_position_indicator.setText(f"{x:07.3f}") self.bbd_current_y_position_indicator.setText(f"{y:07.3f}") def _start_bbd_jog(self, axis: str, direction: int): self._bbd_active_jog = (axis, direction) self._bbd_jog_tick() self._bbd_jog_timer.start() def _bbd_jog_tick(self): if self._bbd_active_jog is None: return axis, direction = self._bbd_active_jog try: self._bbd_worker._jog_step = float(self.lineEdit_10.text()) except ValueError: self._bbd_worker._jog_step = BBD_DEFAULT_JOG_MM self._bbd_worker.queue_jog(axis, direction) def _stop_bbd_jog(self): self._bbd_jog_timer.stop() self._bbd_active_jog = None def _on_set_start_from_pos(self): try: x = float(self.bbd_current_x_position_indicator.text()) y = float(self.bbd_current_y_position_indicator.text()) self.x_start_edit.setText(f"{x:.3f}") self.y_start_edit.setText(f"{y:.3f}") except ValueError: pass def _on_calc_delta(self): try: xs = float(self.x_start_edit.text()) ys = float(self.y_start_edit.text()) cx = float(self.bbd_current_x_position_indicator.text()) cy = float(self.bbd_current_y_position_indicator.text()) self.x_delta_edit.setText(f"{abs(cx - xs):.3f}") self.y_delta_edit.setText(f"{abs(cy - ys):.3f}") except ValueError: pass def _set_bbd_controls_enabled(self, on: bool): for w in ( self.bbd_home_all_btn, self.bbd_enable_all_btn, self.bbd_jog_x_pos_btn, self.bbd_jog_x_neg_btn, self.bbd_jog_y_pos_btn, self.bbd_jog_y_neg_btn, self.bbd_set_current_start_btn, self.bbd_set_delta_current_btn, ): w.setEnabled(on) # ── Oscilloscope slots ──────────────────────────────────────────────────── def _on_oscope_toggle(self, checked: bool): if checked: self.oscope_connect_toggle.setText("Connecting…") self.oscope_connect_toggle.setEnabled(False) self._oscope_worker.queue_connect(self.oscope_ip_edit.text().strip()) else: self._oscope_worker.queue_disconnect() def _on_oscope_connected(self): self.oscope_connect_toggle.setEnabled(True) self.oscope_connect_toggle.setText("Disconnect") def _on_oscope_disconnected(self): self._set_toggle(self.oscope_connect_toggle, False, "Connect") def _on_oscope_failed(self, msg: str): self._set_toggle(self.oscope_connect_toggle, False, "Connect") self.oscope_connect_toggle.setEnabled(True) QMessageBox.warning(self, "Oscilloscope Connection Failed", msg) # ── Persist defaults ────────────────────────────────────────────────────── def _persist_defaults(self): DEFAULTS.t3r_port = self.t3r_comport_edit.text().strip() DEFAULTS.bbd_port = self.bbd202_comport_edit.text().strip() DEFAULTS.oscope_ip = self.oscope_ip_edit.text().strip() DEFAULTS.save_dir = self.scan_save_dir_edit.text().strip() DEFAULTS.save() # ── Camera toggle ───────────────────────────────────────────────────────── def _on_camera_toggle(self, checked: bool): if checked: self.show_camera_toggle.setText("Hide Camera Window") self._camera_win.show() else: self.show_camera_toggle.setText("Show Camera Window") self._camera_win.close() # ── Helios toggle ───────────────────────────────────────────────────────── def _on_helios_toggle(self, checked: bool): if checked: self.show_helios_toggle.setText("Hide Helios Panel") self._helios_win.show() else: self.show_helios_toggle.setText("Show Helios Panel") self._helios_win.close() # ── Scan ────────────────────────────────────────────────────────────────── def _on_browse_save_dir(self): d = QFileDialog.getExistingDirectory( self, "Select Scan Save Directory", self.scan_save_dir_edit.text() ) if d: self.scan_save_dir_edit.setText(d) self._persist_defaults() def _on_start_scan(self): try: plan, prefix, save_dir = self._build_scan_plan() except ValueError as e: QMessageBox.warning(self, "Invalid Scan Parameters", str(e)) return self._launch_scan_worker(plan, prefix, save_dir) def _on_resume_scan_action(self): if self._scan_thread is not None and self._scan_thread.isRunning(): QMessageBox.warning( self, "Scan In Progress", "A scan is already running — abort it before resuming another one." ) return start_dir = self.scan_save_dir_edit.text().strip() or DEFAULTS.save_dir path_str, _ = QFileDialog.getOpenFileName( self, "Select Scan File to Resume", start_dir, "SRAS Scan Files (*.sras)" ) if not path_str: return path = Path(path_str) try: sras = SrasFile(path) statuses = sras.angle_status() except (ValueError, struct.error, OSError) as e: QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}") return hdr = sras.header if (hdr.bytes_per_sample != 1 or hdr.n_channels != len(SCAN_CHANNELS) or abs(hdr.velocity - SCAN_VELOCITY_MM_S) > 1e-3 or abs(hdr.laser_freq - LASER_FREQ_HZ) > 1e-3 or abs(hdr.sample_rate - SCOPE_SAMPLE_RATE) > 1.0): QMessageBox.warning( self, "Cannot Resume Scan", f"{path.name} was recorded with acquisition settings that don't " "match this app's current fixed settings, so appending to it " "would corrupt the file. Resume is only possible for files " "produced by this exact version of the app." ) return dialog = ResumeAngleDialog(statuses, self) if dialog.exec() != QDialog.DialogCode.Accepted: return selected = set(dialog.selected_indices()) if not selected: return # Data is one contiguous stream, so angles past the frontier (the # first incomplete one) can't be skipped over — back-fill the range. frontier_idx = next((s.index for s in statuses if not s.complete), len(statuses)) at_or_past_frontier = {i for i in selected if i >= frontier_idx} if at_or_past_frontier: final = selected | set(range(frontier_idx, max(at_or_past_frontier) + 1)) else: final = selected auto_added = sorted(final - selected) if auto_added: QMessageBox.information( self, "Additional Angles Required", "The scan file has no data yet past angle " f"{frontier_idx + 1}, so angle(s) can't be skipped over. " "The following angle(s) will also be (re)acquired so there's " f"no gap: {[i + 1 for i in auto_added]}" ) targets = [ { "ai": s.index, "data_offset": s.data_offset, "n_rows": s.n_rows, "angle_deg": s.angle_deg, } for s in statuses if s.index in final ] total_rows = sum(t["n_rows"] for t in targets) angle_list = ", ".join(f"{t['ai'] + 1}" for t in targets) reply = QMessageBox.question( self, "Resume Scan", f"Resume {path.name}?\n\n" f"Angle(s) to (re)acquire: {angle_list}\n" f"Total rows to acquire: {total_rows}\n\n" "Existing data for these angle(s) (if any) will be overwritten.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return plan = plan_from_header(sras) resume_info = { "path": path, "targets": targets, "samples_per_frame": hdr.samples_per_frame, } self._launch_scan_worker(plan, path.stem, str(path.parent), resume_info) def _launch_scan_worker(self, plan: ScanPlan, prefix: str, save_dir: str, resume_info: dict | None = None): self._scan_thread = QThread(self) self._scan_worker = ScanWorker( self._bbd_worker, self._t3r_driver, self._oscope_worker, plan, prefix, save_dir, resume_info ) self._scan_worker.moveToThread(self._scan_thread) self._scan_thread.started.connect(self._scan_worker.run) self._scan_worker.row_started.connect(self._scan_progress.on_row_started) self._scan_worker.row_done.connect(self._on_row_done) self._scan_worker.dc_bias_updated.connect(self._scan_progress.update_dc_bias_plot) self._scan_worker.completed.connect(self._on_scan_complete) self._scan_worker.failed.connect(self._on_scan_failed) self._scan_worker.status_msg.connect(lambda m: print(f"[SCAN] {m}")) self._scan_worker.user_prompt.connect(self._on_scan_user_prompt) self._scan_worker.paused_changed.connect(self._scan_progress.on_worker_paused) self.start_scan_btn.setEnabled(False) self._scan_progress.reset_pause_btn() if resume_info is None: self._scan_progress.update_progress(0, plan.per_angle[0].n_rows, 0, plan.n_angles) else: ai0 = resume_info["targets"][0]["ai"] self._scan_progress.update_progress( 0, plan.per_angle[ai0].n_rows, ai0 + 1, plan.n_angles ) self._scan_progress.show() self._scan_thread.start() def _build_scan_plan(self) -> tuple[ScanPlan, str, str]: def _f(w, label): try: return float(w.text()) except ValueError: raise ValueError(f"'{label}' is not a valid number: {w.text()!r}") from None def _i(w, label): try: return int(w.text()) except ValueError: raise ValueError(f"'{label}' is not a valid integer: {w.text()!r}") from None plan = build_plan( _f(self.x_start_edit, "XS"), _f(self.y_start_edit, "YS"), _f(self.x_delta_edit, "XD"), _f(self.y_delta_edit, "YD"), _i(self.num_angles_edit, "NumAngles"), _f(self.row_spacing_edit, "RowSpacing"), laser_freq_hz=LASER_FREQ_HZ, velocity_mm_s=SCAN_VELOCITY_MM_S, rotation_sign=GR_ROTATION_SIGN, ) prefix = self.scan_prefix_edit.text().strip() or "scan" save_dir = self.scan_save_dir_edit.text().strip() or DEFAULTS.save_dir return plan, prefix, save_dir def _on_row_done(self, row: int, n_rows: int, angle_idx: int, n_angles: int): self._scan_progress.update_progress(row, n_rows, angle_idx, n_angles) def _on_scan_complete(self): self._scan_progress.close() self.start_scan_btn.setEnabled(True) QMessageBox.information( self, "Scan Complete", "All rows and angles have been acquired.\n\n" "Hardware will now be disconnected and the application will close." ) self._disconnect_all_and_close() def _on_scan_failed(self, msg: str): self._scan_progress.close() self.start_scan_btn.setEnabled(True) if "aborted" in msg.lower(): QMessageBox.warning(self, "Scan Aborted", msg) else: QMessageBox.critical(self, "Scan Error", f"Scan stopped with an error:\n\n{msg}") def _on_scan_user_prompt(self, title: str, message: str): QMessageBox.information(self, title, message) if self._scan_worker: self._scan_worker.acknowledge_prompt() def _on_abort_scan(self): if self._scan_worker: self._scan_worker.abort() def _on_pause_scan(self, pause: bool): if self._scan_worker: if pause: self._scan_worker.pause() else: self._scan_worker.resume() def _disconnect_all_and_close(self): """Cleanly disconnect all hardware then quit.""" if self._t3r_driver.is_open: self._t3r_driver.disconnect() if self._bbd_worker.is_connected: self._bbd_worker.queue_disconnect() if self._oscope_worker.is_connected: self._oscope_worker.queue_disconnect() if self._helios_worker.is_connected: self._helios_worker.queue_disconnect() self._camera_win.close() self._helios_win.close() QTimer.singleShot(1500, QApplication.instance().quit) # ── Helpers ─────────────────────────────────────────────────────────────── @staticmethod def _set_toggle(btn, checked: bool, text: str): """Silently update a checkable button without triggering its toggled signal.""" btn.blockSignals(True) btn.setChecked(checked) btn.setText(text) btn.setEnabled(True) btn.blockSignals(False) # ── Cleanup ─────────────────────────────────────────────────────────────── def closeEvent(self, event): self._camera_win.close() self._scan_progress.close() self._helios_win.close() if self._t3r_driver.is_open: self._t3r_driver.disconnect() for w in (self._bbd_worker, self._oscope_worker, self._helios_worker): w.stop_worker() for t in (self._bbd_thread, self._oscope_thread, self._helios_thread): t.quit() t.wait(2000) super().closeEvent(event) # ── Entry point ─────────────────────────────────────────────────────────────── def main(): app = QApplication(sys.argv) app.setStyle("Fusion") win = MainWindow() win.show() sys.exit(app.exec()) if __name__ == "__main__": main()