From a0e0151b5da3cecddf3b3e3822e3427480dfa6af Mon Sep 17 00:00:00 2001 From: "Thomas Ales [M S E]" Date: Fri, 22 May 2026 09:38:39 -0500 Subject: [PATCH] pre uc480 integration --- CLAUDE.md | 25 + HELIOS_TEST_README.md | 145 ++ adc_bug.md | 37 + app.py | 545 +++++ app_style.qss | 0 aui_defaults.json | 7 + bbd202_test_app.py | 574 +++++ camera_test_app.py | 252 +++ config.json | 5 +- docs/protocols/helios_register_flags.pdf | Bin 0 -> 96867 bytes genesis_worker.py | 193 ++ hardware/helios_laser.py | 165 +- hardware/pybbd202/bbd20x.py | 7 +- hardware/tektronix_base.py | 11 +- hardware/uc480_camera.py | 78 +- helios_diagnostic.py | 217 ++ helios_terminal.py | 71 + helios_test_app.py | 985 ++++++++ left_off.md | 55 + motion_worker.py | 395 ++++ run_helios_test.sh | 6 + sc3-aui-camera.ui | 76 + sc3-aui-main.ui | 963 ++++++++ sc3-aui-scanprogress.ui | 153 ++ sc3-new.ui | 2635 ++++++++++++++++++++++ sc3_aui_app.py | 1500 ++++++++++++ scan_format.md | 213 ++ sras_viewer.py | 1961 ++++++++++++++++ sras_viewer_requirements.txt | 3 + ui_mainwindow.py | 1321 +++++++++++ 30 files changed, 12557 insertions(+), 41 deletions(-) create mode 100644 CLAUDE.md create mode 100644 HELIOS_TEST_README.md create mode 100644 adc_bug.md create mode 100644 app.py create mode 100644 app_style.qss create mode 100644 aui_defaults.json create mode 100644 bbd202_test_app.py create mode 100644 camera_test_app.py create mode 100644 docs/protocols/helios_register_flags.pdf create mode 100644 genesis_worker.py create mode 100755 helios_diagnostic.py create mode 100755 helios_terminal.py create mode 100755 helios_test_app.py create mode 100644 left_off.md create mode 100644 motion_worker.py create mode 100755 run_helios_test.sh create mode 100644 sc3-aui-camera.ui create mode 100644 sc3-aui-main.ui create mode 100644 sc3-aui-scanprogress.ui create mode 100644 sc3-new.ui create mode 100755 sc3_aui_app.py create mode 100644 scan_format.md create mode 100644 sras_viewer.py create mode 100644 sras_viewer_requirements.txt create mode 100644 ui_mainwindow.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e6724c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# ScanEngine-3 — Claude Code Instructions + +## Project Overview + +Primary language is Python with PyQt6 for all GUIs. This project consists of hardware test apps and control software for cameras, lasers, and motor drivers communicating over serial/USB/UART. Also includes RP2040 C/PIO firmware for embedded peripherals. + +When generating test apps or utilities, use PyQt6 patterns consistent with existing apps in the project (see `helios_test_app.py`, `bbd202_test_app.py`, `camera_test_app.py`). + +## Hardware Debugging + +This project involves hardware test apps (cameras, lasers, motor drivers) communicating over serial/USB/UART. When debugging hardware issues, always consider these common root causes before proposing code changes: + +- **Device held by another process** — check with `lsof /dev/ttyUSB*` or `fuser /dev/ttyUSB*` before diagnosing driver bugs +- **USB re-enumeration** — device may re-appear on a different `/dev` path after a reset; don't assume the port is stable +- **Firmware state that persists across soft resets** — registers, enable bits, and error latches may retain values from a previous run; always read current state before assuming defaults + +When working with hardware protocol docs (PDFs), register definitions and command specs may be split across multiple documents. Ask the user early if the needed section might be in a separate document rather than assuming all info is in one file. + +### PIO (RP2040) + +When writing or debugging PIO programs, proactively check for these common pitfalls: + +- **`pull noblock`** loads OSR from X (scratch register) if the TX FIFO is empty — if X is uninitialised this silently clobbers the output value +- **Invisible pulses** — a single-cycle high pulse is often too narrow to trigger a scope; use a counter to hold the output high for several cycles +- **Off-by-one errors** in division/counting loops — verify the total cycle count matches the intended division ratio including the branch instruction cost diff --git a/HELIOS_TEST_README.md b/HELIOS_TEST_README.md new file mode 100644 index 0000000..12ecf5d --- /dev/null +++ b/HELIOS_TEST_README.md @@ -0,0 +1,145 @@ +# Helios Laser Test Application + +A simple PyQt6 GUI application for testing and controlling the Helios pulsed laser system. + +## Features + +- **Connection Management**: Connect/disconnect to the laser via RS-232 serial port +- **Frequency Control**: Set and query laser pulse frequency (16,700 - 125,000 Hz) +- **Current Control**: Set and query pump diode current (0 - 7,000 mA) +- **Pulse Mode**: Select from three pulse modes: + - Single Pulse + - Continuous Gating + - Continuous Pulsing +- **Laser Enable/Disable**: Control laser emission with dedicated buttons +- **System Information**: Query and display controller and head serial numbers +- **Power Monitoring**: Query and display output power in mW +- **Message Log**: Real-time log of operations and responses + +## Usage + +### Starting the Application + +```bash +/opt/scanengine-3/run_helios_test.sh +``` + +Or directly: + +```bash +source /opt/srasenv/bin/activate +cd /opt/scanengine-3 +python3 helios_test_app.py +``` + +### Connection + +1. **Select Serial Port**: Choose the appropriate serial port from the dropdown (e.g., `/dev/ttyUSB0`) +2. **Refresh Ports**: Click "Refresh Ports" to update the list of available ports +3. **Connect**: Click "Connect" to establish connection with the laser +4. Once connected, the status will show "Connected to [port]" in green + +### Control Tab + +#### Frequency Control +- Enter desired frequency (16,700 - 125,000 Hz) +- Click **Set Frequency** to apply the setting +- Click **Query** to read the current frequency from the laser + +#### Current Control +- Enter desired pump diode current (0 - 7,000 mA) +- Click **Set Current** to apply the setting +- Click **Query** to read the current from the laser + +#### Pulse Mode Control +- Select desired pulse mode from dropdown +- Click **Set Mode** to apply the setting + +#### Laser Control +- **Enable Laser**: Starts laser emission +- **Disable Laser**: Stops laser emission +- **Query Status**: Checks if laser is currently enabled/disabled + +### Monitor Tab + +#### System Information +- **Query All**: Queries all available parameters from the laser +- **Controller SN**: Displays controller serial number +- **Head SN**: Displays laser head serial number + +#### Power Monitoring +- **Query Power**: Reads output power in mW + +#### Messages +- Real-time log of all operations and responses +- Useful for debugging and verifying commands + +## RS-232 Communication Settings + +The driver uses the following settings (automatic, no configuration needed): +- **Baud Rate**: 9600 +- **Data Bits**: 8 +- **Parity**: None +- **Stop Bits**: 1 +- **Flow Control**: None +- **Timeout**: 1 second + +## Command Reference + +The application uses the following Helios commands: + +| Command | Function | Range | +|---------|----------|-------| +| `FP=value` | Set frequency period (ns) | 8,000 - 60,000 | +| `FP?` | Query frequency period | - | +| `PC=value` | Set pump current (mA) | 0 - 7,000 | +| `PC?` | Query pump current | - | +| `PM=value` | Set pulse mode (0-2) | 0=Single, 1=Gate, 2=Continuous | +| `LE=value` | Set laser enable (0/1) | 0=Off, 1=On | +| `LE?` | Query laser enable status | - | +| `PO?` | Query output power (mW) | - | +| `SN?` | Query controller serial | - | +| `HSN?` | Query head serial | - | + +## Safety Notes + +⚠️ **IMPORTANT**: Please follow these safety precautions when using the laser: + +1. **Door Switch**: Ensure the laser enclosure door switch is closed during operation +2. **Laser Disable Pin**: The laser disable pin (pin 3) must be properly connected +3. **Residual Emission**: The laser may emit when the door switch is open - always treat it as potentially dangerous +4. **Start Low**: When setting current, start at lower values and increase gradually +5. **Monitor Power**: Use the "Query Power" function to verify safe output levels + +## Troubleshooting + +### Cannot Connect +- Verify the correct serial port is selected +- Check that the USB-to-RS-232 adapter is properly connected +- Ensure no other application is using the same port +- Try clicking "Refresh Ports" to update the list + +### Commands Not Working +- Verify the laser is connected (status shows green) +- Check that the laser is not in error state +- Review the message log for error details + +### Serial Numbers Not Showing +- The laser may not respond if not properly powered +- Check the serial connection +- Try the "Query All" button to get diagnostic information + +## Files + +- `helios_test_app.py` - Main application +- `run_helios_test.sh` - Launcher script +- `hardware/helios_laser.py` - Helios driver +- `docs/protocols/helios_comms_protocol.pdf` - Official Helios documentation + +## Requirements + +- Python 3.8+ +- PyQt6 +- pyserial +- Helios laser with RS-232 interface +- USB-to-RS-232 adapter (if using modern computer) diff --git a/adc_bug.md b/adc_bug.md new file mode 100644 index 0000000..541c427 --- /dev/null +++ b/adc_bug.md @@ -0,0 +1,37 @@ +# ADC YOFF Sign Bug — sras_viewer.py + +## Status +Fix applied, awaiting user testing. + +## What was wrong + +`DC_YOFF_ADC` in `sras_viewer.py` was `+87.04` instead of `-87.04`. + +The Tektronix scope stores CH3/CH4 waveform data as **signed int8** (−128 to +127), where ADC 0 = screen center. The scope's vertical position for CH3/CH4 is set to `−2.72 div` in `sc3_aui_app.py`, which places 0 V **below** center at ADC count `−2.72 × 32 = −87.04`. The comment in the code had the formula as `-position × (256/8)` (sign flipped), producing `+87.04` instead of the correct `−87.04`. + +## Effect of the bug + +- `adc_to_mv` was off by 272 mV in the negative direction + - ADC −87 (true 0 V signal) → −272 mV (should be ≈ 0 mV) + - ADC 0 (screen center, above ground) → −136 mV (should be +136 mV) +- DC images for CH3/CH4 (Bias A/B) showed large negative voltages, physically impossible for DC bias signals +- RF mask threshold (`mv_to_adc`) was also broken: threshold ADC value ~+87 was being compared against pixel means clustered around −87, so nearly every pixel would have been incorrectly masked + +## The fix + +`sras_viewer.py` line 48: +```python +# Before +DC_YOFF_ADC = 87.04 # ADC count that represents 0 V + +# After +DC_YOFF_ADC = -87.04 # ADC count that represents 0 V +``` +Comment on line 47 also corrected from `-position × (256/8)` to `position × (256/8)`. + +## What to verify during testing + +1. CH3 and CH4 DC images show positive (or near-zero) voltages consistent with the bias signal levels +2. RF (CH1) image is not excessively masked — pixels with a genuine bias signal above the threshold should appear +3. `mv_to_adc(0.0)` should now return −87.04 (not +87.04) +4. The default threshold of 0.125 mV should correspond to ADC ≈ −87.0, not +87.1 diff --git a/app.py b/app.py new file mode 100644 index 0000000..ddddf8e --- /dev/null +++ b/app.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +""" +Scanengine 3 Main Application +""" + +import sys +import json +from pathlib import Path +from PyQt6 import QtWidgets, QtCore +from typing import Optional +import serial.tools.list_ports + +from hardware.coherent_hops_laser import CoherentHOPSLaser, DummyLaser +from hardware.helios_laser import HeliosLaser, PulseMode +from hardware.uc480_camera import UC480Camera, CameraStreamThread +from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y, TriggerBitsServo +from motion_worker import MotionWorker +from scanning.stage_scan_plan_generator import StageScanPlanGenerator +from genesis_worker import GenesisWorker, GenesisCommand +from ui_mainwindow import Ui_MainWindow + +# Page indices in stackedWidget +PAGE_START = 0 +PAGE_OPTIONS = 1 +PAGE_NEWSCAN = 2 +PAGE_CONTINUESCAN = 3 +PAGE_SCAN_PROGRESS = 4 + +CONFIG_PATH = Path(__file__).parent / "config.json" +DEFAULT_CONFIG = { + "stage": { + "serial_port": "", + "trigger": "Disabled", + "scan_velocity_mm_s": 200.0, + "scan_acceleration_mm_s2": 500.0, + "optical_axis_x_mm": 0.0, + "optical_axis_y_mm": 0.0, + }, + "fpga": { + "serial_port": "", + "pulse_divider": 1, + "rowpack_enabled": False, + }, + "t3r": { + "serial_port": "", + "t_axis_current_ma": 0.0, + "gr_axis_current_ma": 0.0, + "t_axis_microstepping": "Full Step", + "gr_axis_microstepping": "Full Step", + }, + "oscilloscope": { + "ip_address": "", + }, + "generation_laser": { + "serial_port": "", + "pulse_frequency_hz": 125000, + "diode_pump_current_ma": 0.0, + }, + "detection_laser": { + "power_mw": 0.0, + }, + "genesis_laser": { + "com_port": "/dev/ttyUSB0", + }, +} + +# Fixed option lists for combo boxes +TRIGGER_OPTIONS = [ + "Disabled", + "Trigger Out: In Motion", + "Trigger Out: Motion Complete", + "Trigger Out: Max Velocity", + "Trigger Out: High at Max Velocity", +] + +MICROSTEPPING_OPTIONS = [ + "Full Step", + "Half Step", + "1/4 Step", + "1/8 Step", + "1/16 Step", + "1/32 Step", +] + + +class ScanWorker(QtCore.QObject): + """Worker object for handling scanning in a separate thread.""" + + scan_started = QtCore.pyqtSignal() + scan_completed = QtCore.pyqtSignal() + scan_failed = QtCore.pyqtSignal(str) + angle_started = QtCore.pyqtSignal(int, int) + line_started = QtCore.pyqtSignal(int, int, float) + current_progress = QtCore.pyqtSignal(int) + overall_progress = QtCore.pyqtSignal(int) + status_message = QtCore.pyqtSignal(str) + + def __init__(self, scan_params, motion_worker): + super().__init__() + self.scan_params = scan_params + self.motion_worker = motion_worker + self.should_stop = False + + @QtCore.pyqtSlot() + def run_scan(self): + """Execute the full scanning process.""" + if self.motion_worker: + self.motion_worker.scanning_active = True + try: + self.scan_started.emit() + # TODO: implement scan execution logic + self.scan_completed.emit() + except Exception as e: + self.scan_failed.emit(str(e)) + finally: + if self.motion_worker: + self.motion_worker.scanning_active = False + + def stop(self): + self.should_stop = True + + +class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.ui = Ui_MainWindow() + self.ui.setupUi(self) + + self.config = self._load_config() + + # Hardware objects + self.motion_worker: Optional[MotionWorker] = None + self.motion_thread: Optional[QtCore.QThread] = None + self.genesis_worker: Optional[GenesisWorker] = None + self.genesis_thread: Optional[QtCore.QThread] = None + self.camera: Optional[UC480Camera] = None + self.camera_stream: Optional[CameraStreamThread] = None + self.vis_laser: Optional[CoherentHOPSLaser] = None + self.ir_laser: Optional[HeliosLaser] = None + self.scan_worker: Optional[ScanWorker] = None + self.scan_thread: Optional[QtCore.QThread] = None + + self._connect_signals() + self._init_genesis_worker() + self.ui.stackedWidget.setCurrentIndex(PAGE_START) + + # ------------------------------------------------------------------ + # Config + # ------------------------------------------------------------------ + + def _load_config(self) -> dict: + if CONFIG_PATH.exists(): + try: + with open(CONFIG_PATH) as f: + cfg = json.load(f) + for section, values in DEFAULT_CONFIG.items(): + cfg.setdefault(section, {}) + for key, val in values.items(): + cfg[section].setdefault(key, val) + return cfg + except Exception: + pass + return {k: dict(v) for k, v in DEFAULT_CONFIG.items()} + + def _save_config(self): + with open(CONFIG_PATH, "w") as f: + json.dump(self.config, f, indent=2) + + # ------------------------------------------------------------------ + # Signal wiring + # ------------------------------------------------------------------ + + def _connect_signals(self): + # Start page + self.ui.start_new_scan_btn.clicked.connect(self._go_to_newscan) + self.ui.resume_scan_btn.clicked.connect(self._go_to_continuescan) + self.ui.edit_options_btn.clicked.connect(self._go_to_options) + + # Options page + self.ui.options_save_settings_btn.clicked.connect(self._on_options_save) + self.ui.options_cancel_btn.clicked.connect(self._go_to_start) + self.ui.stage_test_connection_btn.clicked.connect(self._on_test_stage_connection) + self.ui.fpga_connect_button.clicked.connect(self._on_fpga_connect) + self.ui.fpga_refresh_ports_btn.clicked.connect(self._on_fpga_refresh_ports) + self.ui.refresh_serial_ports_btn.clicked.connect(self._on_refresh_serial_ports) + self.ui.scope_connect_btn.clicked.connect(self._on_scope_connect) + self.ui.generation_connect_button.clicked.connect(self._on_generation_connect) + self.ui.detection_test_btn.clicked.connect(self._on_detection_test) + self.ui.t3r_connect_btn.clicked.connect(self._on_t3r_connect) + self.ui.t3r_refresh_ports_btn.clicked.connect(self._on_t3r_refresh_ports) + + # New scan page + self.ui.newscan_browse_folders_btn.clicked.connect(self._on_newscan_browse) + self.ui.newscan_set_current_as_start_btn.clicked.connect(self._on_newscan_set_start) + self.ui.newscan_get_delta_from_current_btn.clicked.connect(self._on_newscan_get_delta) + self.ui.newscan_toggle_vis_laser_btn.clicked.connect(self._on_newscan_toggle_vis_laser) + self.ui.newscan_continue_to_next_btn.clicked.connect(self._on_newscan_start_scan) + self.ui.newscan_jog_x_pos_btn.pressed.connect(self._on_jog_x_pos_pressed) + self.ui.newscan_jog_x_pos_btn.released.connect(self._on_jog_stop) + self.ui.newscan_jog_x_neg_btn.pressed.connect(self._on_jog_x_neg_pressed) + self.ui.newscan_jog_x_neg_btn.released.connect(self._on_jog_stop) + self.ui.newscan_jog_y_pos_btn.pressed.connect(self._on_jog_y_pos_pressed) + self.ui.newscan_jog_y_pos_btn.released.connect(self._on_jog_stop) + self.ui.newscan_jog_y_neg_btn.pressed.connect(self._on_jog_y_neg_pressed) + self.ui.newscan_jog_y_neg_btn.released.connect(self._on_jog_stop) + + # Continue scan page + self.ui.continuescan_resume_scans.clicked.connect(self._on_resume_scan) + + # Scan progress page + self.ui.abort_scan_button.clicked.connect(self._on_abort_scan) + + # ------------------------------------------------------------------ + # Navigation + # ------------------------------------------------------------------ + + def _go_to_start(self): + self.ui.stackedWidget.setCurrentIndex(PAGE_START) + + def _go_to_options(self): + self._populate_options_page() + self.ui.stackedWidget.setCurrentIndex(PAGE_OPTIONS) + + def _go_to_newscan(self): + self._populate_newscan_page() + self.ui.stackedWidget.setCurrentIndex(PAGE_NEWSCAN) + + def _go_to_continuescan(self): + self._populate_continuescan_page() + self.ui.stackedWidget.setCurrentIndex(PAGE_CONTINUESCAN) + + def _go_to_scan_progress(self): + self.ui.stackedWidget.setCurrentIndex(PAGE_SCAN_PROGRESS) + + # ------------------------------------------------------------------ + # Options page + # ------------------------------------------------------------------ + + def _get_serial_ports(self) -> list[str]: + return sorted(p.device for p in serial.tools.list_ports.comports()) + + def _populate_combo(self, combo: QtWidgets.QComboBox, items: list[str], current: str): + """Refill a combo box, re-selecting `current` if present.""" + combo.blockSignals(True) + combo.clear() + combo.addItems(items) + idx = combo.findText(current) + if idx >= 0: + combo.setCurrentIndex(idx) + elif current: + combo.insertItem(0, current) + combo.setCurrentIndex(0) + combo.blockSignals(False) + + def _populate_options_page(self): + cfg = self.config + ports = self._get_serial_ports() + + # ---- Kinematics tab ---- + self.ui.scan_velocity_edit.setText(str(cfg["stage"]["scan_velocity_mm_s"])) + self.ui.scan_accel_edit.setText(str(cfg["stage"]["scan_acceleration_mm_s2"])) + self.ui.optical_axis_x_edit.setText(str(cfg["stage"]["optical_axis_x_mm"])) + self.ui.optical_axis_y_edit.setText(str(cfg["stage"]["optical_axis_y_mm"])) + self.ui.stage_serial_edit.setText(cfg["stage"]["serial_port"]) + self._populate_combo(self.ui.stage_trigger_combo, TRIGGER_OPTIONS, cfg["stage"]["trigger"]) + + # ---- Detection / VIS tab ---- + self.ui.detection_power_edit.setText(str(cfg["detection_laser"]["power_mw"])) + + # ---- Generation / IR tab ---- + self._populate_combo(self.ui.comboBox, ports, cfg["generation_laser"]["serial_port"]) + self.ui.generation_pulse_freq_edit.setText(str(cfg["generation_laser"]["pulse_frequency_hz"])) + self.ui.diode_pump_current_edit.setText(str(cfg["generation_laser"]["diode_pump_current_ma"])) + + # ---- PulseDecimator tab ---- + self._populate_combo(self.ui.fpga_serial_port, ports, cfg["fpga"]["serial_port"]) + self.ui.fpga_divider_value_edit.setText(str(cfg["fpga"]["pulse_divider"])) + self.ui.checkBox.setChecked(cfg["fpga"]["rowpack_enabled"]) + + # ---- T3R-SL tab ---- + self._populate_combo(self.ui.t3r_serial_port_edit, ports, cfg["t3r"]["serial_port"]) + self.ui.lineEdit.setText(str(cfg["t3r"]["t_axis_current_ma"])) + self.ui.lineEdit_2.setText(str(cfg["t3r"]["gr_axis_current_ma"])) + self._populate_combo(self.ui.comboBox_2, MICROSTEPPING_OPTIONS, cfg["t3r"]["t_axis_microstepping"]) + self._populate_combo(self.ui.comboBox_3, MICROSTEPPING_OPTIONS, cfg["t3r"]["gr_axis_microstepping"]) + + # ---- Oscilloscope tab ---- + self.ui.scope_ip_address_edit.setText(cfg["oscilloscope"]["ip_address"]) + + def _on_options_save(self): + try: + # Kinematics + self.config["stage"]["scan_velocity_mm_s"] = float(self.ui.scan_velocity_edit.text()) + self.config["stage"]["scan_acceleration_mm_s2"] = float(self.ui.scan_accel_edit.text()) + self.config["stage"]["optical_axis_x_mm"] = float(self.ui.optical_axis_x_edit.text()) + self.config["stage"]["optical_axis_y_mm"] = float(self.ui.optical_axis_y_edit.text()) + self.config["stage"]["serial_port"] = self.ui.stage_serial_edit.text().strip() + self.config["stage"]["trigger"] = self.ui.stage_trigger_combo.currentText() + + # Detection / VIS + self.config["detection_laser"]["power_mw"] = float(self.ui.detection_power_edit.text()) + + # Generation / IR + self.config["generation_laser"]["serial_port"] = self.ui.comboBox.currentText() + self.config["generation_laser"]["pulse_frequency_hz"] = int(self.ui.generation_pulse_freq_edit.text()) + self.config["generation_laser"]["diode_pump_current_ma"] = float(self.ui.diode_pump_current_edit.text()) + + # PulseDecimator + self.config["fpga"]["serial_port"] = self.ui.fpga_serial_port.currentText() + self.config["fpga"]["pulse_divider"] = int(self.ui.fpga_divider_value_edit.text()) + self.config["fpga"]["rowpack_enabled"] = self.ui.checkBox.isChecked() + + # T3R-SL + self.config["t3r"]["serial_port"] = self.ui.t3r_serial_port_edit.currentText() + self.config["t3r"]["t_axis_current_ma"] = float(self.ui.lineEdit.text()) + self.config["t3r"]["gr_axis_current_ma"] = float(self.ui.lineEdit_2.text()) + self.config["t3r"]["t_axis_microstepping"] = self.ui.comboBox_2.currentText() + self.config["t3r"]["gr_axis_microstepping"] = self.ui.comboBox_3.currentText() + + # Oscilloscope + self.config["oscilloscope"]["ip_address"] = self.ui.scope_ip_address_edit.text().strip() + + except ValueError as e: + QtWidgets.QMessageBox.warning(self, "Invalid input", str(e)) + return + + self._save_config() + self._go_to_start() + + def _refresh_serial_ports_for_combos(self, *combos: QtWidgets.QComboBox): + """Re-populate serial port combos, preserving current selections.""" + ports = self._get_serial_ports() + for combo in combos: + self._populate_combo(combo, ports, combo.currentText()) + + def _on_refresh_serial_ports(self): + self._refresh_serial_ports_for_combos(self.ui.comboBox) + + def _on_fpga_refresh_ports(self): + self._refresh_serial_ports_for_combos(self.ui.fpga_serial_port) + + def _on_t3r_refresh_ports(self): + self._refresh_serial_ports_for_combos(self.ui.t3r_serial_port_edit) + + def _on_test_stage_connection(self): + pass # TODO + + def _on_fpga_connect(self): + pass # TODO + + def _on_scope_connect(self): + pass # TODO + + def _on_generation_connect(self): + pass # TODO + + def _on_detection_test(self): + pass # TODO + + def _on_t3r_connect(self): + pass # TODO + + # ------------------------------------------------------------------ + # New scan page + # ------------------------------------------------------------------ + + def _populate_newscan_page(self): + self.ui.newscan_save_directory_edit.setText(str(Path.home() / "scans")) + + def _on_newscan_browse(self): + directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select save directory") + if directory: + self.ui.newscan_save_directory_edit.setText(directory) + + def _on_newscan_set_start(self): + pass # TODO: capture current stage position as scan start + + def _on_newscan_get_delta(self): + pass # TODO: capture current stage position as scan end (compute delta) + + def _on_newscan_toggle_vis_laser(self): + pass # TODO: toggle vis laser on/off + + def _on_newscan_start_scan(self): + scan_params = self._build_scan_params() + if scan_params is None: + return + self._start_scan(scan_params) + + def _build_scan_params(self) -> Optional[dict]: + """Read newscan page widgets and return scan parameter dict, or None on error.""" + try: + x_start = float(self.ui.newscan_start_x_coord_edit.text()) + y_start = float(self.ui.newscan_start_y_coord_edit.text()) + x_delta = float(self.ui.newscan_delta_x_coord_edit.text()) + y_delta = float(self.ui.newscan_delta_y_coord_edit.text()) + except ValueError: + QtWidgets.QMessageBox.warning(self, "Invalid input", "Scan coordinates must be numbers.") + return None + + pixel_size_map = { + self.ui.newscan_50_micron_radio: 0.05, + self.ui.newscan_100_micron_radio: 0.10, + self.ui.newscan_250_micron_radio: 0.25, + } + row_spacing = next( + (v for btn, v in pixel_size_map.items() if btn.isChecked()), 0.10 + ) + + return { + "x_start_mm": x_start, + "y_start_mm": y_start, + "x_delta_mm": x_delta, + "y_delta_mm": y_delta, + "row_spacing_mm": row_spacing, + "num_angles": int(self.ui.newscan_num_angles_combo.currentText()), + "friendly_name": self.ui.newscan_friendly_name_edit.text(), + "file_prefix": self.ui.newcsan_file_prefix_edit.text(), + "save_directory": self.ui.newscan_save_directory_edit.text(), + "scan_velocity_mm_s": self.config["stage"]["scan_velocity_mm_s"], + "scan_acceleration_mm_s2": self.config["stage"]["scan_acceleration_mm_s2"], + } + + # ------------------------------------------------------------------ + # Jog controls + # ------------------------------------------------------------------ + + def _on_jog_x_pos_pressed(self): + pass # TODO + + def _on_jog_x_neg_pressed(self): + pass # TODO + + def _on_jog_y_pos_pressed(self): + pass # TODO + + def _on_jog_y_neg_pressed(self): + pass # TODO + + def _on_jog_stop(self): + pass # TODO + + # ------------------------------------------------------------------ + # Continue scan page + # ------------------------------------------------------------------ + + def _populate_continuescan_page(self): + pass # TODO: populate list of interrupted scans + + def _on_resume_scan(self): + pass # TODO: resume selected scan + + # ------------------------------------------------------------------ + # Scan execution + # ------------------------------------------------------------------ + + def _start_scan(self, scan_params: dict): + self.scan_thread = QtCore.QThread() + self.scan_worker = ScanWorker(scan_params, self.motion_worker) + self.scan_worker.moveToThread(self.scan_thread) + + self.scan_thread.started.connect(self.scan_worker.run_scan) + self.scan_worker.scan_started.connect(self._on_scan_started) + self.scan_worker.scan_completed.connect(self._on_scan_completed) + self.scan_worker.scan_failed.connect(self._on_scan_failed) + self.scan_worker.current_progress.connect(self.ui.scanning_scan_progbar.setValue) + self.scan_worker.overall_progress.connect(self.ui.scanning_overall_progbar.setValue) + self.scan_worker.status_message.connect(self.ui.scanning_stage_state_label.setText) + + self._go_to_scan_progress() + self.scan_thread.start() + + @QtCore.pyqtSlot() + def _on_scan_started(self): + self.ui.abort_scan_button.setEnabled(True) + + @QtCore.pyqtSlot() + def _on_scan_completed(self): + self._cleanup_scan_thread() + QtWidgets.QMessageBox.information(self, "Scan complete", "Scan finished successfully.") + self._go_to_start() + + @QtCore.pyqtSlot(str) + def _on_scan_failed(self, error: str): + self._cleanup_scan_thread() + QtWidgets.QMessageBox.critical(self, "Scan failed", error) + self._go_to_start() + + def _on_abort_scan(self): + if self.scan_worker: + self.scan_worker.stop() + + def _cleanup_scan_thread(self): + if self.scan_thread: + self.scan_thread.quit() + self.scan_thread.wait() + self.scan_thread = None + self.scan_worker = None + + # ------------------------------------------------------------------ + # Genesis laser worker + # ------------------------------------------------------------------ + + def _init_genesis_worker(self): + com_port = self.config.get("genesis_laser", {}).get("com_port", "/dev/ttyUSB0") + self.genesis_worker = GenesisWorker(com_port) + self.genesis_thread = QtCore.QThread() + self.genesis_worker.moveToThread(self.genesis_thread) + self.genesis_thread.started.connect(self.genesis_worker.run) + self.genesis_thread.start() + + def _cleanup_genesis_worker(self): + if self.genesis_worker: + self.genesis_worker.stop() + if self.genesis_thread: + self.genesis_thread.quit() + self.genesis_thread.wait() + self.genesis_worker = None + self.genesis_thread = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def closeEvent(self, event): + self._cleanup_genesis_worker() + self._cleanup_scan_thread() + if self.motion_thread: + self.motion_thread.quit() + self.motion_thread.wait() + super().closeEvent(event) + + +def main(): + app = QtWidgets.QApplication(sys.argv) + qss_path = Path(__file__).parent / "app_style.qss" + if qss_path.exists(): + app.setStyleSheet(qss_path.read_text()) + window = MainWindow() + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/app_style.qss b/app_style.qss new file mode 100644 index 0000000..e69de29 diff --git a/aui_defaults.json b/aui_defaults.json new file mode 100644 index 0000000..3f31259 --- /dev/null +++ b/aui_defaults.json @@ -0,0 +1,7 @@ +{ + "t3r_port": "/dev/ttyACM0", + "bbd_port": "/dev/ttyAPT", + "oscope_ip": "192.168.100.105", + "laser_freq_hz": 20000.0, + "save_dir": "/opt/scanengine-3/scans" +} \ No newline at end of file diff --git a/bbd202_test_app.py b/bbd202_test_app.py new file mode 100644 index 0000000..f3ef352 --- /dev/null +++ b/bbd202_test_app.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +""" +BBD202 Stage Controller Test Application +PyQt6 GUI for jogging the stage and configuring trigger outputs. +Thomas Ales | Mar 2026 +""" + +import sys +import queue +import time + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QDoubleSpinBox, + QStatusBar, QMessageBox, QGridLayout, QCheckBox, QFrame +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer +from PyQt6.QtGui import QFont, QKeySequence, QShortcut + +from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y +from hardware.pybbd202.apt_constants import TriggerBitsServo + + +# ── Worker thread ───────────────────────────────────────────────────────────── + +class StageCommand: + def __init__(self, cmd, **kwargs): + self.cmd = cmd + self.params = kwargs + + +class StageWorker(QObject): + connected = pyqtSignal() + disconnected = pyqtSignal() + conn_failed = pyqtSignal(str) + position_updated = pyqtSignal(float, float) # x_mm, y_mm + status_updated = pyqtSignal(bool, bool, bool, bool) # x_homed, y_homed, x_moving, y_moving + trigger_read = pyqtSignal(int, int) # x_mode, y_mode + error_occurred = pyqtSignal(str) + home_done = pyqtSignal(str) # 'x', 'y', or 'both' + + def __init__(self): + super().__init__() + self._driver = None + self._queue = queue.Queue() + self._running = True + self._last_x = None + self._last_y = None + + def enqueue(self, cmd, **kwargs): + self._queue.put(StageCommand(cmd, **kwargs)) + + def run(self): + self._poll_timer = QTimer() + self._poll_timer.setInterval(200) + self._poll_timer.timeout.connect(self._poll_status) + + while self._running: + try: + cmd = self._queue.get(timeout=0.05) + self._dispatch(cmd) + except queue.Empty: + pass + + def _dispatch(self, cmd): + try: + if cmd.cmd == 'connect': + self._do_connect(cmd.params['port']) + elif cmd.cmd == 'disconnect': + self._do_disconnect() + elif cmd.cmd == 'home_x': + self._do_home(AXIS_X, 'x') + elif cmd.cmd == 'home_y': + self._do_home(AXIS_Y, 'y') + elif cmd.cmd == 'jog': + self._do_jog(cmd.params['axis'], cmd.params['distance_mm']) + elif cmd.cmd == 'move_abs': + self._do_move_abs(cmd.params['axis'], cmd.params['pos_mm']) + elif cmd.cmd == 'set_velocity': + self._do_set_velocity(cmd.params['axis'], + cmd.params['vel'], cmd.params['accel']) + elif cmd.cmd == 'set_trigger': + self._do_set_trigger(cmd.params['axis'], cmd.params['mode']) + elif cmd.cmd == 'get_trigger': + self._do_get_trigger() + elif cmd.cmd == 'stop': + pass # TODO: add stop message if needed + except TimeoutError as e: + self.error_occurred.emit(f"Timeout: {e}") + except ValueError as e: + self.error_occurred.emit(f"Value error: {e}") + except Exception as e: + self.error_occurred.emit(f"Error: {e}") + + def _do_connect(self, port): + try: + self._driver = ThorlabsServoDriver() + self._driver.connect(port=port) + self._driver.enable_axis(AXIS_X) + self._driver.enable_axis(AXIS_Y) + self._driver.start_polling(interval=0.2) + time.sleep(0.5) # let first polls come in + self.connected.emit() + except Exception as e: + self._driver = None + self.conn_failed.emit(str(e)) + + def _do_disconnect(self): + if self._driver: + try: + self._driver.disconnect() + except Exception: + pass + self._driver = None + self.disconnected.emit() + + def _do_home(self, axis, label): + self._driver.home_axis(axis, timeout=60.0) + self.home_done.emit(label) + + def _do_jog(self, axis, distance_mm): + self._driver.move_axis_relative(axis, distance_mm) + + def _do_move_abs(self, axis, pos_mm): + self._driver.move_axis_absolute(axis, pos_mm) + + def _do_set_velocity(self, axis, vel, accel): + self._driver.set_velocity_params(axis, max_velocity=vel, acceleration=accel) + + def _do_set_trigger(self, axis, mode): + self._driver.set_trigger(axis, mode) + + def _do_get_trigger(self): + x_mode = int(self._driver.get_trigger(AXIS_X)) + y_mode = int(self._driver.get_trigger(AXIS_Y)) + self.trigger_read.emit(x_mode, y_mode) + + def _poll_status(self): + if not self._driver: + return + x = self._driver.positions[0] + y = self._driver.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) + self.status_updated.emit( + self._driver.am_homed[0], self._driver.am_homed[1], + self._driver.am_moving[0], self._driver.am_moving[1] + ) + + def stop(self): + self._running = False + + +# ── Main window ─────────────────────────────────────────────────────────────── + +TRIG_OPTIONS = [ + ("Disabled", 0x00), + ("In: Logic High", TriggerBitsServo.TRIGIN_HIGH), + ("In: Relative Move", TriggerBitsServo.TRIGIN_RELMOVE), + ("In: Absolute Move", TriggerBitsServo.TRIGIN_ABSMOVE), + ("In: Home Move", TriggerBitsServo.TRIGIN_HOMEMOVE), + ("Out: Logic High", TriggerBitsServo.TRIGOUT_HIGH), + ("Out: In Motion", TriggerBitsServo.TRIGOUT_INMOTION), + ("Out: Motion Complete", TriggerBitsServo.TRIGOUT_MOTIONCOMPLETE), + ("Out: At Max Velocity", TriggerBitsServo.TRIGOUT_MAXVELOCITY), + ("Out: High + Max Vel", TriggerBitsServo.TRIGOUT_MAXV), +] + + +class BBD202TestApp(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("BBD202 Stage Controller Test") + self.resize(700, 620) + + self._worker = StageWorker() + self._thread = QThread() + self._worker.moveToThread(self._thread) + self._thread.started.connect(self._worker.run) + + self._worker.connected.connect(self._on_connected) + self._worker.disconnected.connect(self._on_disconnected) + self._worker.conn_failed.connect(self._on_conn_failed) + self._worker.position_updated.connect(self._on_position_updated) + self._worker.status_updated.connect(self._on_status_updated) + self._worker.trigger_read.connect(self._on_trigger_read) + self._worker.error_occurred.connect(self._on_error) + self._worker.home_done.connect(self._on_home_done) + + self._thread.start() + + # Periodic status poll from worker — drive via a QTimer in main thread + self._status_timer = QTimer() + self._status_timer.setInterval(200) + self._status_timer.timeout.connect(self._poll_worker_status) + + self._build_ui() + self._set_connected(False) + + # ── UI construction ─────────────────────────────────────────────────────── + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setSpacing(8) + + root.addWidget(self._build_connection_group()) + root.addWidget(self._build_status_group()) + root.addWidget(self._build_jog_group()) + root.addWidget(self._build_velocity_group()) + root.addWidget(self._build_trigger_group()) + + self.statusbar = QStatusBar() + self.setStatusBar(self.statusbar) + self.statusbar.showMessage("Not connected.") + + def _build_connection_group(self): + grp = QGroupBox("Connection") + lay = QHBoxLayout(grp) + + lay.addWidget(QLabel("Serial Port:")) + self.le_port = QLineEdit("/dev/ttyUSB1") + self.le_port.setMaximumWidth(150) + lay.addWidget(self.le_port) + + self.btn_connect = QPushButton("Connect") + self.btn_connect.clicked.connect(self._on_connect_clicked) + lay.addWidget(self.btn_connect) + + self.btn_disconnect = QPushButton("Disconnect") + self.btn_disconnect.clicked.connect(self._on_disconnect_clicked) + lay.addWidget(self.btn_disconnect) + + lay.addStretch() + return grp + + def _build_status_group(self): + grp = QGroupBox("Status") + grid = QGridLayout(grp) + + bold = QFont() + bold.setBold(True) + + grid.addWidget(QLabel(""), 0, 0) + lbl_x = QLabel("X"); lbl_x.setFont(bold) + lbl_y = QLabel("Y"); lbl_y.setFont(bold) + grid.addWidget(lbl_x, 0, 1, Qt.AlignmentFlag.AlignCenter) + grid.addWidget(lbl_y, 0, 2, Qt.AlignmentFlag.AlignCenter) + + grid.addWidget(QLabel("Position (mm):"), 1, 0) + self.lbl_x_pos = QLabel("---") + self.lbl_y_pos = QLabel("---") + grid.addWidget(self.lbl_x_pos, 1, 1, Qt.AlignmentFlag.AlignCenter) + grid.addWidget(self.lbl_y_pos, 1, 2, Qt.AlignmentFlag.AlignCenter) + + grid.addWidget(QLabel("Homed:"), 2, 0) + self.lbl_x_homed = QLabel("No") + self.lbl_y_homed = QLabel("No") + grid.addWidget(self.lbl_x_homed, 2, 1, Qt.AlignmentFlag.AlignCenter) + grid.addWidget(self.lbl_y_homed, 2, 2, Qt.AlignmentFlag.AlignCenter) + + grid.addWidget(QLabel("Moving:"), 3, 0) + self.lbl_x_moving = QLabel("No") + self.lbl_y_moving = QLabel("No") + grid.addWidget(self.lbl_x_moving, 3, 1, Qt.AlignmentFlag.AlignCenter) + grid.addWidget(self.lbl_y_moving, 3, 2, Qt.AlignmentFlag.AlignCenter) + + # Home buttons + self.btn_home_x = QPushButton("Home X") + self.btn_home_y = QPushButton("Home Y") + self.btn_home_x.clicked.connect(lambda: self._worker.enqueue('home_x')) + self.btn_home_y.clicked.connect(lambda: self._worker.enqueue('home_y')) + grid.addWidget(self.btn_home_x, 4, 1) + grid.addWidget(self.btn_home_y, 4, 2) + + return grp + + def _build_jog_group(self): + grp = QGroupBox("Jog / Manual Move") + lay = QVBoxLayout(grp) + + # Step size + step_row = QHBoxLayout() + step_row.addWidget(QLabel("Step size (mm):")) + self.dsb_step = QDoubleSpinBox() + self.dsb_step.setRange(0.001, 50.0) + self.dsb_step.setValue(1.0) + self.dsb_step.setDecimals(3) + self.dsb_step.setSingleStep(0.5) + self.dsb_step.setMaximumWidth(100) + step_row.addWidget(self.dsb_step) + step_row.addStretch() + lay.addLayout(step_row) + + # Jog buttons — arrow-style grid + jog_grid = QGridLayout() + jog_grid.setSpacing(4) + + self.btn_y_pos = QPushButton("Y +") + self.btn_y_neg = QPushButton("Y −") + self.btn_x_neg = QPushButton("← X −") + self.btn_x_pos = QPushButton("X + →") + + for btn in (self.btn_y_pos, self.btn_y_neg, + self.btn_x_neg, self.btn_x_pos): + btn.setMinimumWidth(80) + + jog_grid.addWidget(self.btn_y_pos, 0, 1) + jog_grid.addWidget(self.btn_x_neg, 1, 0) + jog_grid.addWidget(self.btn_x_pos, 1, 2) + jog_grid.addWidget(self.btn_y_neg, 2, 1) + + self.btn_y_pos.clicked.connect( + lambda: self._worker.enqueue('jog', axis=AXIS_Y, + distance_mm=self.dsb_step.value())) + self.btn_y_neg.clicked.connect( + lambda: self._worker.enqueue('jog', axis=AXIS_Y, + distance_mm=-self.dsb_step.value())) + self.btn_x_pos.clicked.connect( + lambda: self._worker.enqueue('jog', axis=AXIS_X, + distance_mm=self.dsb_step.value())) + self.btn_x_neg.clicked.connect( + lambda: self._worker.enqueue('jog', axis=AXIS_X, + distance_mm=-self.dsb_step.value())) + + lay.addLayout(jog_grid) + + # Absolute move row + abs_row = QHBoxLayout() + abs_row.addWidget(QLabel("Go to X (mm):")) + self.dsb_abs_x = QDoubleSpinBox() + self.dsb_abs_x.setRange(0.0, 110.0) + self.dsb_abs_x.setDecimals(3) + self.dsb_abs_x.setMaximumWidth(100) + abs_row.addWidget(self.dsb_abs_x) + + abs_row.addWidget(QLabel("Y (mm):")) + self.dsb_abs_y = QDoubleSpinBox() + self.dsb_abs_y.setRange(0.0, 75.0) + self.dsb_abs_y.setDecimals(3) + self.dsb_abs_y.setMaximumWidth(100) + abs_row.addWidget(self.dsb_abs_y) + + btn_go = QPushButton("Move") + btn_go.clicked.connect(self._on_abs_move_clicked) + abs_row.addWidget(btn_go) + abs_row.addStretch() + lay.addLayout(abs_row) + + return grp + + def _build_velocity_group(self): + grp = QGroupBox("Velocity Parameters") + lay = QHBoxLayout(grp) + + lay.addWidget(QLabel("Max Vel (mm/s):")) + self.dsb_vel = QDoubleSpinBox() + self.dsb_vel.setRange(0.1, 300.0) + self.dsb_vel.setValue(20.0) + self.dsb_vel.setDecimals(1) + self.dsb_vel.setMaximumWidth(90) + lay.addWidget(self.dsb_vel) + + lay.addWidget(QLabel("Accel (mm/s²):")) + self.dsb_accel = QDoubleSpinBox() + self.dsb_accel.setRange(1.0, 2000.0) + self.dsb_accel.setValue(100.0) + self.dsb_accel.setDecimals(1) + self.dsb_accel.setMaximumWidth(90) + lay.addWidget(self.dsb_accel) + + lay.addWidget(QLabel("Axis:")) + self.cmb_vel_axis = QComboBox() + self.cmb_vel_axis.addItems(["X", "Y"]) + lay.addWidget(self.cmb_vel_axis) + + btn_set_vel = QPushButton("Apply") + btn_set_vel.clicked.connect(self._on_set_velocity_clicked) + lay.addWidget(btn_set_vel) + + btn_read_vel = QPushButton("Read") + btn_read_vel.clicked.connect(self._on_read_velocity_clicked) + lay.addWidget(btn_read_vel) + + lay.addStretch() + return grp + + def _build_trigger_group(self): + grp = QGroupBox("Trigger Configuration") + lay = QVBoxLayout(grp) + + grid = QGridLayout() + bold = QFont(); bold.setBold(True) + + lbl_x = QLabel("X Axis"); lbl_x.setFont(bold) + lbl_y = QLabel("Y Axis"); lbl_y.setFont(bold) + grid.addWidget(lbl_x, 0, 1, Qt.AlignmentFlag.AlignCenter) + grid.addWidget(lbl_y, 0, 2, Qt.AlignmentFlag.AlignCenter) + grid.addWidget(QLabel("Trigger Mode:"), 1, 0) + + self.cmb_trig_x = QComboBox() + self.cmb_trig_y = QComboBox() + for name, _ in TRIG_OPTIONS: + self.cmb_trig_x.addItem(name) + self.cmb_trig_y.addItem(name) + grid.addWidget(self.cmb_trig_x, 1, 1) + grid.addWidget(self.cmb_trig_y, 1, 2) + + lay.addLayout(grid) + + btn_row = QHBoxLayout() + btn_apply = QPushButton("Apply Trigger Settings") + btn_apply.clicked.connect(self._on_apply_trigger_clicked) + btn_row.addWidget(btn_apply) + + btn_read = QPushButton("Read from Controller") + btn_read.clicked.connect(lambda: self._worker.enqueue('get_trigger')) + btn_row.addWidget(btn_read) + btn_row.addStretch() + lay.addLayout(btn_row) + + return grp + + # ── UI state helpers ────────────────────────────────────────────────────── + + def _set_connected(self, connected): + self.btn_connect.setEnabled(not connected) + self.btn_disconnect.setEnabled(connected) + self.le_port.setEnabled(not connected) + + for w in (self.btn_home_x, self.btn_home_y, + self.btn_x_pos, self.btn_x_neg, + self.btn_y_pos, self.btn_y_neg, + self.dsb_step, self.dsb_abs_x, self.dsb_abs_y, + self.dsb_vel, self.dsb_accel, + self.cmb_vel_axis, self.cmb_trig_x, self.cmb_trig_y): + w.setEnabled(connected) + + # The Apply / Read / Move buttons — find them by iterating children + for btn in self.findChildren(QPushButton): + if btn not in (self.btn_connect, self.btn_disconnect): + btn.setEnabled(connected) + + # Keep connect/disconnect right + self.btn_connect.setEnabled(not connected) + self.btn_disconnect.setEnabled(connected) + + def _poll_worker_status(self): + """Drive the worker's status poll from main thread timer.""" + if self._worker._driver: + self._worker._poll_status() + + # ── Slots ───────────────────────────────────────────────────────────────── + + def _on_connect_clicked(self): + port = self.le_port.text().strip() + if not port: + QMessageBox.warning(self, "Input Error", "Enter a serial port.") + return + self.btn_connect.setEnabled(False) + self.statusbar.showMessage(f"Connecting to {port}…") + self._worker.enqueue('connect', port=port) + + def _on_disconnect_clicked(self): + self._status_timer.stop() + self._worker.enqueue('disconnect') + + def _on_connected(self): + self._set_connected(True) + self._status_timer.start() + self.statusbar.showMessage("Connected.") + + def _on_disconnected(self): + self._set_connected(False) + self._status_timer.stop() + self.lbl_x_pos.setText("---") + self.lbl_y_pos.setText("---") + self.lbl_x_homed.setText("No") + self.lbl_y_homed.setText("No") + self.lbl_x_moving.setText("No") + self.lbl_y_moving.setText("No") + self.statusbar.showMessage("Disconnected.") + + def _on_conn_failed(self, msg): + self._set_connected(False) + self.statusbar.showMessage(f"Connection failed: {msg}") + QMessageBox.critical(self, "Connection Failed", msg) + + def _on_position_updated(self, x, y): + self.lbl_x_pos.setText(f"{x:.3f}") + self.lbl_y_pos.setText(f"{y:.3f}") + + def _on_status_updated(self, x_homed, y_homed, x_moving, y_moving): + self.lbl_x_homed.setText("Yes" if x_homed else "No") + self.lbl_y_homed.setText("Yes" if y_homed else "No") + self.lbl_x_moving.setText("Yes" if x_moving else "No") + self.lbl_y_moving.setText("Yes" if y_moving else "No") + + def _on_home_done(self, axis): + self.statusbar.showMessage(f"{axis.upper()} homing complete.") + + def _on_abs_move_clicked(self): + self._worker.enqueue('move_abs', axis=AXIS_X, + pos_mm=self.dsb_abs_x.value()) + self._worker.enqueue('move_abs', axis=AXIS_Y, + pos_mm=self.dsb_abs_y.value()) + + def _on_set_velocity_clicked(self): + axis = AXIS_X if self.cmb_vel_axis.currentText() == "X" else AXIS_Y + self._worker.enqueue('set_velocity', axis=axis, + vel=self.dsb_vel.value(), + accel=self.dsb_accel.value()) + self.statusbar.showMessage("Velocity parameters applied.") + + def _on_read_velocity_clicked(self): + axis = AXIS_X if self.cmb_vel_axis.currentText() == "X" else AXIS_Y + if not self._worker._driver: + return + try: + params = self._worker._driver.get_velocity_params(axis) + self.dsb_vel.setValue(params['max_velocity']) + self.dsb_accel.setValue(params['acceleration']) + self.statusbar.showMessage( + f"Read: vel={params['max_velocity']:.1f} mm/s, " + f"accel={params['acceleration']:.1f} mm/s²") + except Exception as e: + self._on_error(str(e)) + + def _on_apply_trigger_clicked(self): + x_mode = TRIG_OPTIONS[self.cmb_trig_x.currentIndex()][1] + y_mode = TRIG_OPTIONS[self.cmb_trig_y.currentIndex()][1] + self._worker.enqueue('set_trigger', axis=AXIS_X, mode=int(x_mode)) + self._worker.enqueue('set_trigger', axis=AXIS_Y, mode=int(y_mode)) + self.statusbar.showMessage("Trigger settings applied.") + + def _on_trigger_read(self, x_mode, y_mode): + def _find_idx(mode_val): + for i, (_, v) in enumerate(TRIG_OPTIONS): + if int(v) == mode_val: + return i + return 0 + + self.cmb_trig_x.setCurrentIndex(_find_idx(x_mode)) + self.cmb_trig_y.setCurrentIndex(_find_idx(y_mode)) + self.statusbar.showMessage( + f"Trigger read: X=0x{x_mode:02X}, Y=0x{y_mode:02X}") + + def _on_error(self, msg): + self.statusbar.showMessage(f"Error: {msg}") + QMessageBox.warning(self, "Error", msg) + + # ── Cleanup ─────────────────────────────────────────────────────────────── + + def closeEvent(self, event): + self._status_timer.stop() + if self._worker._driver: + self._worker._do_disconnect() + self._worker.stop() + self._thread.quit() + self._thread.wait(3000) + event.accept() + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +if __name__ == '__main__': + app = QApplication(sys.argv) + app.setStyle('Fusion') + win = BBD202TestApp() + win.show() + sys.exit(app.exec()) diff --git a/camera_test_app.py b/camera_test_app.py new file mode 100644 index 0000000..9abff0e --- /dev/null +++ b/camera_test_app.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +UC480 Camera Test Application +Simple PyQt6 GUI for testing and viewing the uC480/uEye camera. +""" + +import sys +import logging + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QGroupBox, QLabel, QPushButton, QDoubleSpinBox, QSpinBox, + QStatusBar, QSizePolicy +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QPixmap, QImage + +from hardware.uc480_camera import UC480Camera, CameraStreamThread + +logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') +logger = logging.getLogger(__name__) + + +class CameraTestWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("UC480 Camera Test") + self.resize(900, 700) + + self.camera: UC480Camera | None = None + self.stream_thread: CameraStreamThread | None = None + + self._build_ui() + self._update_controls_enabled() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + root = QHBoxLayout(central) + root.setContentsMargins(8, 8, 8, 8) + + # Left: video display + self.lbl_image = QLabel("No camera connected") + self.lbl_image.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.lbl_image.setMinimumSize(640, 480) + self.lbl_image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.lbl_image.setStyleSheet("background: #111; color: #888; border: 1px solid #444;") + root.addWidget(self.lbl_image, stretch=1) + + # Right: controls panel + panel = QWidget() + panel.setFixedWidth(230) + panel_layout = QVBoxLayout(panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + root.addWidget(panel) + + # Connection group + grp_conn = QGroupBox("Connection") + conn_layout = QVBoxLayout(grp_conn) + self.btn_connect = QPushButton("Connect") + self.btn_connect.clicked.connect(self._on_connect) + self.btn_disconnect = QPushButton("Disconnect") + self.btn_disconnect.clicked.connect(self._on_disconnect) + conn_layout.addWidget(self.btn_connect) + conn_layout.addWidget(self.btn_disconnect) + panel_layout.addWidget(grp_conn) + + # Sensor info group + grp_info = QGroupBox("Sensor Info") + info_layout = QVBoxLayout(grp_info) + self.lbl_sensor_name = QLabel("Name: —") + self.lbl_resolution = QLabel("Resolution: —") + self.lbl_pixel_size = QLabel("Pixel size: —") + for lbl in (self.lbl_sensor_name, self.lbl_resolution, self.lbl_pixel_size): + lbl.setWordWrap(True) + info_layout.addWidget(lbl) + panel_layout.addWidget(grp_info) + + # Exposure group + grp_exp = QGroupBox("Exposure (ms)") + exp_layout = QHBoxLayout(grp_exp) + self.spin_exposure = QDoubleSpinBox() + self.spin_exposure.setRange(0.01, 10000.0) + self.spin_exposure.setDecimals(2) + self.spin_exposure.setSingleStep(1.0) + self.spin_exposure.setValue(10.0) + self.btn_set_exposure = QPushButton("Set") + self.btn_set_exposure.setFixedWidth(40) + self.btn_set_exposure.clicked.connect(self._on_set_exposure) + exp_layout.addWidget(self.spin_exposure) + exp_layout.addWidget(self.btn_set_exposure) + panel_layout.addWidget(grp_exp) + + # Gain group + grp_gain = QGroupBox("Master Gain (0–100)") + gain_layout = QHBoxLayout(grp_gain) + self.spin_gain = QSpinBox() + self.spin_gain.setRange(0, 100) + self.spin_gain.setValue(0) + self.btn_set_gain = QPushButton("Set") + self.btn_set_gain.setFixedWidth(40) + self.btn_set_gain.clicked.connect(self._on_set_gain) + gain_layout.addWidget(self.spin_gain) + gain_layout.addWidget(self.btn_set_gain) + panel_layout.addWidget(grp_gain) + + # Stream control group + grp_stream = QGroupBox("Stream") + stream_layout = QVBoxLayout(grp_stream) + self.btn_start_stream = QPushButton("Start Stream") + self.btn_start_stream.clicked.connect(self._on_start_stream) + self.btn_stop_stream = QPushButton("Stop Stream") + self.btn_stop_stream.clicked.connect(self._on_stop_stream) + stream_layout.addWidget(self.btn_start_stream) + stream_layout.addWidget(self.btn_stop_stream) + panel_layout.addWidget(grp_stream) + + panel_layout.addStretch() + + # Status bar + self.statusBar().showMessage("Not connected") + + # ------------------------------------------------------------------ + # Button handlers + # ------------------------------------------------------------------ + + def _on_connect(self): + if self.camera is not None: + self.statusBar().showMessage("Already connected") + return + + self.camera = UC480Camera(camera_id=1) + self.camera.error_occurred.connect(self._on_camera_error) + + if not self.camera.initialize(): + self.statusBar().showMessage("Failed to initialize camera") + self.camera = None + return + + info = self.camera.get_sensor_info() + self.lbl_sensor_name.setText(f"Name: {info.get('sensor_name', '?')}") + self.lbl_resolution.setText( + f"Resolution: {info.get('max_width', '?')}×{info.get('max_height', '?')}" + ) + self.lbl_pixel_size.setText(f"Pixel size: {info.get('pixel_size', '?')} µm") + + self.statusBar().showMessage("Camera connected") + self._update_controls_enabled() + + def _on_disconnect(self): + self._on_stop_stream() + if self.camera is not None: + self.camera.cleanup() + self.camera = None + self.lbl_image.setText("No camera connected") + self.lbl_sensor_name.setText("Name: —") + self.lbl_resolution.setText("Resolution: —") + self.lbl_pixel_size.setText("Pixel size: —") + self.statusBar().showMessage("Disconnected") + self._update_controls_enabled() + + def _on_set_exposure(self): + if self.camera is None: + return + val = self.spin_exposure.value() + if self.camera.set_exposure(val): + actual = self.camera.get_exposure() + shown = f"{actual:.2f}" if actual is not None else f"{val:.2f}" + self.statusBar().showMessage(f"Exposure set to {shown} ms") + else: + self.statusBar().showMessage("Failed to set exposure") + + def _on_set_gain(self): + if self.camera is None: + return + val = self.spin_gain.value() + if self.camera.set_gain(val): + self.statusBar().showMessage(f"Gain set to {val}") + else: + self.statusBar().showMessage("Failed to set gain") + + def _on_start_stream(self): + if self.camera is None or self.stream_thread is not None: + return + self.stream_thread = CameraStreamThread(self.camera) + self.stream_thread.frame_ready.connect(self._on_frame) + self.stream_thread.error_occurred.connect(self._on_camera_error) + self.stream_thread.start() + self.statusBar().showMessage("Streaming…") + self._update_controls_enabled() + + def _on_stop_stream(self): + if self.stream_thread is not None: + self.stream_thread.stop() + self.stream_thread = None + self.statusBar().showMessage("Stream stopped") + self._update_controls_enabled() + + # ------------------------------------------------------------------ + # Slots + # ------------------------------------------------------------------ + + def _on_frame(self, image: QImage): + scaled = image.scaled( + self.lbl_image.width(), + self.lbl_image.height(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self.lbl_image.setPixmap(QPixmap.fromImage(scaled)) + + def _on_camera_error(self, msg: str): + self.statusBar().showMessage(f"Error: {msg}") + logger.error(msg) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _update_controls_enabled(self): + connected = self.camera is not None + streaming = self.stream_thread is not None + + self.btn_connect.setEnabled(not connected) + self.btn_disconnect.setEnabled(connected) + self.btn_set_exposure.setEnabled(connected) + self.spin_exposure.setEnabled(connected) + self.btn_set_gain.setEnabled(connected) + self.spin_gain.setEnabled(connected) + self.btn_start_stream.setEnabled(connected and not streaming) + self.btn_stop_stream.setEnabled(streaming) + + def closeEvent(self, event): + self._on_disconnect() + super().closeEvent(event) + + +# --------------------------------------------------------------------------- + +def main(): + app = QApplication(sys.argv) + window = CameraTestWindow() + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/config.json b/config.json index fe6d4d7..cc76fdc 100644 --- a/config.json +++ b/config.json @@ -1,9 +1,12 @@ { + "genesis_laser": { + "com_port": "/dev/ttyUSB0" + }, "detection_laser": { "scan_power_mw": "125" }, "generation_laser": { - "com_port": "/dev/ttyUSB0", + "com_port": "/dev/ttyACM0", "frequency_hz": "20000", "pump_diode_current_ma": "750", "focusing_frequency_hz": "20000", diff --git a/docs/protocols/helios_register_flags.pdf b/docs/protocols/helios_register_flags.pdf new file mode 100644 index 0000000000000000000000000000000000000000..817b0896d18237c809b4fca67ec18a12c23414a8 GIT binary patch literal 96867 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 self.status_update_interval: + self.update_laser_status() + self.last_status_update_time = current_time + + except Exception as e: + print(f"Error in genesis worker loop: {e}") + self.error_occurred.emit(str(e)) + + # Cleanup on exit + self.disconnect() + print("Genesis laser worker thread stopped") + + def connect(self) -> bool: + """Establish connection to the laser""" + try: + if self.serial_comm.connect(self.port, self.baudrate): + self.is_connected = True + print(f"Connected to Genesis laser on {self.port}") + return True + else: + print(f"Failed to open serial port {self.port}") + return False + except Exception as e: + print(f"Connection error: {e}") + return False + + def disconnect(self): + """Disconnect from the laser""" + if self.is_connected: + self.serial_comm.disconnect() + self.is_connected = False + self.disconnected.emit() + print("Disconnected from Genesis laser") + + def process_command(self, cmd: GenesisCommand): + """Process a command from the queue""" + if not self.is_connected: + self.error_occurred.emit("Laser not connected") + return + + try: + if cmd.cmd_type == "query_all": + result = self.query_all_status() + self.query_completed.emit(result) + elif cmd.cmd_type == "query_current": + result = {"current": self.laser_control.get_current_actual()} + self.query_completed.emit(result) + elif cmd.cmd_type == "query_interlock": + result = {"interlock": self.laser_control.get_interlock_status()} + self.query_completed.emit(result) + elif cmd.cmd_type == "set_current": + value = cmd.params.get("value", 0) + success = self.laser_control.set_current(int(value)) + self.query_completed.emit({"success": success}) + elif cmd.cmd_type == "set_shutter": + state = cmd.params.get("state", False) + success = self.laser_control.set_shutter(state) + self.query_completed.emit({"success": success}) + else: + self.error_occurred.emit(f"Unknown command: {cmd.cmd_type}") + except Exception as e: + self.error_occurred.emit(f"Command execution error: {e}") + + def update_laser_status(self): + """Query and emit current laser status""" + if not self.is_connected: + return + + try: + status = { + "connected": True, + "current_actual": self.laser_control.get_current_actual(), + "interlock_status": self.laser_control.get_interlock_status(), + "ldd_enable_status": self.laser_control.get_ldd_enable_status(), + "psglue_in_status": self.laser_control.get_psglue_in_status(), + "psglue_out_status": self.laser_control.get_psglue_out_status(), + "head_dio_status": self.laser_control.get_head_dio_status(), + } + + # Only emit if something changed + if status != self.last_laser_state: + self.last_laser_state = status + self.laser_info_updated.emit(status) + + except Exception as e: + print(f"Error updating laser status: {e}") + + def query_all_status(self) -> dict: + """Query all laser status information""" + return { + "connected": True, + "current_actual": self.laser_control.get_current_actual(), + "interlock_status": self.laser_control.get_interlock_status(), + "ldd_enable_status": self.laser_control.get_ldd_enable_status(), + "psglue_in_status": self.laser_control.get_psglue_in_status(), + "psglue_out_status": self.laser_control.get_psglue_out_status(), + "head_dio_status": self.laser_control.get_head_dio_status(), + } + + def queue_command(self, cmd: GenesisCommand): + """Queue a command for execution""" + self.command_queue.put(cmd) + + def stop(self): + """Stop the worker thread""" + self.running = False diff --git a/hardware/helios_laser.py b/hardware/helios_laser.py index 70b8c35..99b5625 100644 --- a/hardware/helios_laser.py +++ b/hardware/helios_laser.py @@ -126,17 +126,34 @@ class HeliosLaser: Send a query and read response. Args: - command: ASCII query command (without CR) + command: ASCII query command (without CR or ?) Returns: - Response string or None if error + Response value string or None if error """ - if not self._send_command(command): - return None - try: - response = self.serial.readline().decode('ascii').strip() + # Clear any pending data in the buffer + self.serial.reset_input_buffer() + time.sleep(0.05) + + if not self._send_command(command): + return None + + time.sleep(0.2) # Give device time to respond + + response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip() logger.debug(f"Query '{command}' response: {response}") + + # Helios format: "COMMAND = VALUE UNIT" + # Extract just the value part + if '=' in response: + parts = response.split('=') + if len(parts) >= 2: + value_part = parts[1].strip() + # Remove unit suffix if present (e.g., "ns", "mA", "mW") + value = value_part.split()[0] + return value + return response except Exception as e: @@ -160,7 +177,12 @@ class HeliosLaser: # Convert frequency to period in nanoseconds period_ns = int(1e9 / frequency) - command = f"FP={period_ns}" + # Clamp to valid range (8000-60000 ns) + if not (8000 <= period_ns <= 60000): + logger.error(f"Period {period_ns} ns out of range (8000-60000)") + return False + + command = f"LDF {period_ns}" return self._send_command(command) def set_current_ma(self, current: int) -> bool: @@ -168,16 +190,16 @@ class HeliosLaser: Set pump diode current in mA. Args: - current: Current in mA (0 - 7000) + current: Current in mA (0 - 2000 for this model) Returns: True if successful """ - if not (0 <= current <= 7000): - logger.error(f"Current {current} mA out of range (0-7000)") + if not (0 <= current <= 2000): + logger.error(f"Current {current} mA out of range (0-2000)") return False - command = f"PC={current}" + command = f"LDS {current}" return self._send_command(command) def set_pulse_mode(self, mode: PulseMode) -> bool: @@ -190,7 +212,7 @@ class HeliosLaser: Returns: True if successful """ - command = f"PM={mode.value}" + command = f"LDG {mode.value}" return self._send_command(command) def set_laser_enable(self, enable: bool) -> bool: @@ -203,7 +225,7 @@ class HeliosLaser: Returns: True if successful """ - command = f"LE={1 if enable else 0}" + command = f"LDO {1 if enable else 0}" success = self._send_command(command) if success: @@ -219,12 +241,12 @@ class HeliosLaser: Returns: True if laser is enabled """ - response = self._query("LE?") + response = self._query("LDO") if response: try: return int(response) == 1 except ValueError: - logger.error(f"Invalid response for LE?: {response}") + logger.error(f"Invalid response for LDO: {response}") return False def get_frequency_hz(self) -> Optional[int]: @@ -234,13 +256,13 @@ class HeliosLaser: Returns: Frequency in Hz or None if error """ - response = self._query("FP?") + response = self._query("LDF") if response: try: period_ns = int(response) return int(1e9 / period_ns) except (ValueError, ZeroDivisionError): - logger.error(f"Invalid response for FP?: {response}") + logger.error(f"Invalid response for LDF: {response}") return None def get_current_ma(self) -> Optional[int]: @@ -250,12 +272,12 @@ class HeliosLaser: Returns: Current in mA or None if error """ - response = self._query("PC?") + response = self._query("LDS") if response: try: return int(response) except ValueError: - logger.error(f"Invalid response for PC?: {response}") + logger.error(f"Invalid response for LDS: {response}") return None def get_power_mw(self) -> Optional[float]: @@ -265,12 +287,12 @@ class HeliosLaser: Returns: Power in mW or None if error """ - response = self._query("PO?") + response = self._query("HMP") if response: try: return float(response) except ValueError: - logger.error(f"Invalid response for PO?: {response}") + logger.error(f"Invalid response for HMP: {response}") return None def get_controller_serial(self) -> Optional[str]: @@ -280,7 +302,7 @@ class HeliosLaser: Returns: Serial number string or None if error """ - return self._query("SN?") + return self._query("CSR") def get_head_serial(self) -> Optional[str]: """ @@ -289,7 +311,104 @@ class HeliosLaser: Returns: Serial number string or None if error """ - return self._query("HSN?") + return self._query("HSR") + + def get_status_registers(self) -> tuple: + """ + Query LER, LCE, and CCE status registers. + + Each register is a bitmask (sum of flags). Non-zero values indicate + active faults. Reset with reset_faults(). + + Returns: + Tuple of (ler, lce, cce) as ints, or None for each on error. + """ + def _read_reg(cmd): + resp = self._query(cmd) + if resp is not None: + try: + return int(resp) + except ValueError: + logger.error(f"Invalid response for {cmd}: {resp}") + return None + + ler = _read_reg("LER") + lce = _read_reg("LCE") + cce = _read_reg("CCE") + return (ler, lce, cce) + + def reset_faults(self) -> bool: + """ + Execute the controller reset sequence to clear status registers. + + Protocol-specified sequence: CCE 0 -> LCE 0 -> LER 0 + + Returns: + True if all three commands sent successfully + """ + ok = True + ok = self._send_command("CCE 0") and ok + time.sleep(0.1) + ok = self._send_command("LCE 0") and ok + time.sleep(0.1) + ok = self._send_command("LER 0") and ok + if ok: + logger.info("Fault reset sequence sent") + return ok + + def get_remote_enable(self) -> Optional[bool]: + """ + Query the remote enable state (LRE - activates utility connector pin 8). + + Returns: + True if remote enable is active, False if not, None on error + """ + response = self._query("LRE") + if response is not None: + try: + return int(response) == 1 + except ValueError: + logger.error(f"Invalid response for LRE: {response}") + return None + + def send_raw_command(self, command: str) -> Optional[str]: + """ + Send a raw command string and return the raw response. + + Useful for diagnostics. Sends *command* + CR, waits briefly, + then reads whatever the device returns (up to the first CR or timeout). + + Returns: + Raw response string (decoded, stripped) or None on error. + """ + if not self.is_connected or not self.serial: + logger.error("Not connected to laser") + return None + try: + self.serial.reset_input_buffer() + time.sleep(0.05) + self.serial.write((command + '\r').encode('ascii')) + time.sleep(0.3) + raw = self.serial.read_until(b'\r') + if not raw: + raw = self.serial.read(self.serial.in_waiting) + return raw.decode('ascii', errors='replace').strip() + except Exception as e: + logger.error(f"send_raw_command error: {e}") + return None + + def set_remote_enable(self, enable: bool) -> bool: + """ + Set the remote enable state (LRE - utility connector pin 8). + + Args: + enable: True to activate remote enable, False to deactivate + + Returns: + True if successful + """ + command = f"LRE {1 if enable else 0}" + return self._send_command(command) def __del__(self): """Destructor - ensure cleanup""" diff --git a/hardware/pybbd202/bbd20x.py b/hardware/pybbd202/bbd20x.py index 0e49428..2c7b9cc 100644 --- a/hardware/pybbd202/bbd20x.py +++ b/hardware/pybbd202/bbd20x.py @@ -265,6 +265,11 @@ class ThorlabsServoDriver(): if(msg['status_bits'] & StatusBits.MOT_SB_HOMED): self.am_homed[ch] = True + if(msg['status_bits'] & StatusBits.MOT_SB_ENABLED): + self.am_enabled[ch] = True + else: + self.am_enabled[ch] = False + def _update0x0464(self, msg): ''' _update0x0464 - internal function for MOVE_COMPLETED messages. @@ -344,7 +349,7 @@ class ThorlabsServoDriver(): else: raise ValueError("I don't know that axis!") - self.send_and_wait(0x0443, timeout=timeout, chan_ident=1, + self.send_and_wait(0x0443, timeout=timeout, retries=0, chan_ident=1, destination=axis, source=0x01) return diff --git a/hardware/tektronix_base.py b/hardware/tektronix_base.py index 6098e04..1fbc0fe 100644 --- a/hardware/tektronix_base.py +++ b/hardware/tektronix_base.py @@ -1411,10 +1411,13 @@ class TektronixOscilloscopeBase: if not self.get_fastframe_state(): raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)") - # Get the frame count - frame_count = self.get_fastframe_count() - if frame_count <= 0: - raise RuntimeError(f"Invalid FastFrame count: {frame_count}") + # Use the number of frames actually acquired, not the configured maximum. + # If the stage stops early, fewer triggers arrive and the scope captures + # fewer frames than configured — reading the configured count would block. + acquired = int(self.query("ACQuire:NUMFRAMESACQuired?")) + if acquired <= 0: + raise RuntimeError(f"Scope acquired 0 FastFrame frames — no data to read") + frame_count = acquired # Send a single CURVe? query - scope will return all frames self.write("CURVe?") diff --git a/hardware/uc480_camera.py b/hardware/uc480_camera.py index 2125f6a..6ea2630 100644 --- a/hardware/uc480_camera.py +++ b/hardware/uc480_camera.py @@ -4,11 +4,14 @@ Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library. Provides camera control, live streaming, and image capture capabilities. """ +import time import numpy as np from pyueye import ueye from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtGui import QImage import logging +import threading +from contextlib import contextmanager from typing import Optional, Tuple logger = logging.getLogger(__name__) @@ -24,17 +27,21 @@ class UC480Camera(QObject): frame_ready = pyqtSignal(QImage) # Emitted when a new frame is captured error_occurred = pyqtSignal(str) # Emitted when an error occurs - def __init__(self, camera_id: int = 0): + def __init__(self, camera_id: int = 1): """ Initialize the uC480 camera driver. Args: - camera_id: Camera ID (0 for first available camera) + camera_id: Camera ID (1-based; use is_GetCameraList to find IDs) """ super().__init__() self.camera_id = camera_id - self.h_cam = ueye.HIDS(camera_id) + # IS_ALLOW_STARTER_FW_UPLOAD (0x10000): instructs the SDK to block + # inside is_InitCamera until firmware upload and USB re-enumeration + # finish. Without this flag, the handle becomes invalid the moment + # the device reconnects and the SDK segfaults on the very next call. + self.h_cam = ueye.HIDS(camera_id | 0x10000) self.is_initialized = False self.is_capturing = False @@ -55,6 +62,9 @@ class UC480Camera(QObject): self.bytes_per_pixel = 3 self.color_mode = ueye.IS_CM_BGR8_PACKED + # Lock to serialize parameter changes that require stopping live video + self._settings_lock = threading.Lock() + def initialize(self) -> bool: """ Initialize the camera and allocate memory. @@ -63,12 +73,23 @@ class UC480Camera(QObject): True if successful, False otherwise """ try: - # Initialize camera - ret = ueye.is_InitCamera(self.h_cam, None) - if ret != ueye.IS_SUCCESS: - logger.error(f"Failed to initialize camera: {ret}") - self.error_occurred.emit(f"Failed to initialize camera: {ret}") - return False + # Initialize camera. After is_ExitCamera the UI124x series + # resets and re-enumerates on USB (firmware reload), so retry + # for up to ~10 s if IS_CANT_OPEN_DEVICE is returned. + for attempt in range(20): + ret = ueye.is_InitCamera(self.h_cam, None) + if ret == ueye.IS_SUCCESS: + break + if ret == ueye.IS_CANT_OPEN_DEVICE and attempt < 19: + logger.debug(f"Camera not ready (IS_CANT_OPEN_DEVICE), retrying ({attempt+1}/20)…") + time.sleep(0.5) + # h_cam value is consumed by a failed init on some SDK + # versions; recreate it to avoid IS_INVALID_CAMERA_HANDLE + self.h_cam = ueye.HIDS(self.camera_id | 0x10000) + else: + logger.error(f"Failed to initialize camera: {ret}") + self.error_occurred.emit(f"Failed to initialize camera: {ret}") + return False # Get sensor info ret = ueye.is_GetSensorInfo(self.h_cam, self.sensor_info) @@ -161,9 +182,12 @@ class UC480Camera(QObject): self.mem_ptr = None if self.is_initialized: - ueye.is_ExitCamera(self.h_cam) + ret = ueye.is_ExitCamera(self.h_cam) self.is_initialized = False - logger.info("Camera resources released") + if ret == ueye.IS_SUCCESS: + logger.info("Camera resources released") + else: + logger.error(f"is_ExitCamera failed: {ret} — camera handle may still be held by daemon") def start_capture(self) -> bool: """ @@ -201,14 +225,37 @@ class UC480Camera(QObject): return True ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT) + self.is_capturing = False # Always reset, even if the call fails if ret != ueye.IS_SUCCESS: logger.error(f"Failed to stop capture: {ret}") return False - self.is_capturing = False logger.info("Video capture stopped") return True + @contextmanager + def _capture_paused(self): + """ + Context manager that temporarily stops live video while a camera + parameter is being changed, then restarts it. Many IDS cameras + return IS_CANT_COMMUNICATE_WITH_DRIVER (17) or IS_NO_SUCCESS (-1) + when gain/exposure commands are issued during active capture. + """ + with self._settings_lock: + was_capturing = self.is_capturing + if was_capturing: + ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT) + self.is_capturing = False + try: + yield + finally: + if was_capturing: + ret = ueye.is_CaptureVideo(self.h_cam, ueye.IS_DONT_WAIT) + if ret == ueye.IS_SUCCESS: + self.is_capturing = True + else: + logger.error(f"Failed to restart capture after settings change: {ret}") + def get_frame(self) -> Optional[QImage]: """ Capture a single frame from the camera. @@ -405,6 +452,13 @@ class UC480Camera(QObject): if ret == ueye.IS_SUCCESS: logger.debug(f"Master gain set to {master_gain}") return True + elif ret == ueye.IS_CANT_COMMUNICATE_WITH_DRIVER: + logger.error( + f"Hardware gain not supported by this camera model " + f"(IS_CANT_COMMUNICATE_WITH_DRIVER). " + f"Consider using gain boost instead." + ) + return False else: logger.error(f"Failed to set gain: {ret}") return False diff --git a/helios_diagnostic.py b/helios_diagnostic.py new file mode 100755 index 0000000..0a93e32 --- /dev/null +++ b/helios_diagnostic.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Helios Laser Serial Communication Diagnostic Tool +Helps troubleshoot communication issues with the Helios laser. +""" + +import serial +import time +import sys + +def test_port(port, baudrate=9600): + """Test basic communication on a serial port.""" + print(f"\n{'='*60}") + print(f"Testing {port} at {baudrate} baud") + print(f"{'='*60}") + + try: + ser = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1.0 + ) + print(f"✓ Port opened successfully") + time.sleep(0.1) + + # Try to query the controller serial number + print("\nSending: 'SN?'") + ser.write(b'SN?\r') + time.sleep(0.5) + + response = ser.readline().decode('ascii', errors='replace').strip() + print(f"Response: '{response}'") + + if response: + print(f"✓ Got response: {response}") + return True, response + else: + print(f"✗ No response received") + + # Try head serial number + print("\nSending: 'HSN?'") + ser.write(b'HSN?\r') + time.sleep(0.5) + + response = ser.readline().decode('ascii', errors='replace').strip() + print(f"Response: '{response}'") + + if response: + print(f"✓ Got response: {response}") + ser.close() + return True, response + else: + print(f"✗ No response received") + + # Try laser enable status + print("\nSending: 'LE?'") + ser.write(b'LE?\r') + time.sleep(0.5) + + response = ser.readline().decode('ascii', errors='replace').strip() + print(f"Response: '{response}'") + + if response: + print(f"✓ Got response: {response}") + ser.close() + return True, response + else: + print(f"✗ No response received") + + ser.close() + return False, "No response to any query" + + except Exception as e: + print(f"✗ Error: {e}") + return False, str(e) + + +def test_raw_communication(port, baudrate=9600): + """Test raw serial communication and display hex.""" + print(f"\n{'='*60}") + print(f"Raw Communication Test: {port} at {baudrate} baud") + print(f"{'='*60}") + + try: + ser = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=2.0 + ) + print(f"✓ Port opened successfully") + time.sleep(0.2) + + # Send a simple query + command = b'SN?\r' + print(f"\nSending command (hex): {command.hex()}") + print(f"Sending command (ascii): {command}") + + ser.write(command) + time.sleep(0.5) + + # Read response byte by byte + response = b'' + while True: + byte = ser.read(1) + if not byte: + break + response += byte + if byte == b'\n' or byte == b'\r': + break + + print(f"\nRaw response (hex): {response.hex()}") + print(f"Raw response (ascii): {response}") + print(f"Response length: {len(response)} bytes") + + # Check for common issues + if not response: + print("✗ No response - device may not be responding or wrong baud rate") + elif response == b'\r' or response == b'\n': + print("⚠ Only got line terminator - device may be echoing but not responding to command") + else: + print("✓ Got a response!") + + ser.close() + return True + + except Exception as e: + print(f"✗ Error: {e}") + return False + + +def test_echo(port, baudrate=9600): + """Test if the device echoes commands back.""" + print(f"\n{'='*60}") + print(f"Echo Test: {port} at {baudrate} baud") + print(f"{'='*60}") + + try: + ser = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1.0 + ) + + # Send a test character + test_char = b'T' + print(f"Sending test character: {test_char}") + ser.write(test_char) + time.sleep(0.1) + + echo = ser.read(1) + if echo == test_char: + print(f"✓ Device echoes input") + elif echo: + print(f"⚠ Device sent something but not the same: {echo}") + else: + print(f"✗ No echo") + + ser.close() + return True + + except Exception as e: + print(f"✗ Error: {e}") + return False + + +def main(): + """Run diagnostic tests.""" + port = "/dev/ttyUSB2" + + if len(sys.argv) > 1: + port = sys.argv[1] + + print(f"\n{'#'*60}") + print(f"# Helios Laser Serial Diagnostic Tool") + print(f"# Testing port: {port}") + print(f"{'#'*60}") + + # Test standard baud rate + success, response = test_port(port, 9600) + + if not success: + print("\n" + "="*60) + print("Standard baud rate (9600) failed. Trying alternatives...") + print("="*60) + + # Try other common baud rates + for baudrate in [115200, 19200, 4800, 2400]: + success, response = test_port(port, baudrate) + if success: + print(f"\n✓ SUCCESS! Device responds at {baudrate} baud") + break + else: + print(f"\n✓ SUCCESS! Device responds at 9600 baud") + + # Run additional diagnostics + print("\n") + test_raw_communication(port, 9600) + + print("\n") + test_echo(port, 9600) + + print(f"\n{'#'*60}") + print("# Diagnostic Tests Complete") + print(f"{'#'*60}\n") + + +if __name__ == "__main__": + main() diff --git a/helios_terminal.py b/helios_terminal.py new file mode 100755 index 0000000..c4157c1 --- /dev/null +++ b/helios_terminal.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Simple serial terminal for manual Helios laser testing. +Allows sending raw commands and viewing responses. +""" + +import serial +import sys +from threading import Thread +import time + +def read_from_port(ser): + """Read data from serial port and display it.""" + while True: + try: + if ser.in_waiting: + data = ser.read(ser.in_waiting) + print(f"\n[RX] {data.decode('ascii', errors='replace')}", end='') + sys.stdout.flush() + except: + break + time.sleep(0.01) + +def main(): + """Run interactive serial terminal.""" + port = "/dev/ttyUSB2" + + if len(sys.argv) > 1: + port = sys.argv[1] + + try: + ser = serial.Serial( + port=port, + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=0.1 + ) + print(f"Connected to {port} at 9600 baud") + print("Type commands and press Enter. Type 'quit' to exit.\n") + + # Start reader thread + reader_thread = Thread(target=read_from_port, args=(ser,), daemon=True) + reader_thread.start() + + while True: + try: + user_input = input("[TX] ") + if user_input.lower() == 'quit': + break + + # Send command with carriage return + command = user_input + '\r' + ser.write(command.encode('ascii')) + time.sleep(0.1) + + except KeyboardInterrupt: + break + except Exception as e: + print(f"Error: {e}") + + ser.close() + print("\nDisconnected") + + except Exception as e: + print(f"Failed to open {port}: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/helios_test_app.py b/helios_test_app.py new file mode 100755 index 0000000..b112aa0 --- /dev/null +++ b/helios_test_app.py @@ -0,0 +1,985 @@ +#!/usr/bin/env python3 +""" +Helios Laser Test Application +Simple PyQt6 GUI for testing and controlling the Helios laser. +""" + +import sys +import logging +from typing import Optional +from enum import Enum + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox, + QStatusBar, QMessageBox, QTabWidget, QTextEdit +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject +from PyQt6.QtGui import QFont + +from hardware.helios_laser import HeliosLaser, PulseMode + +# --------------------------------------------------------------------------- +# Status register bit definitions (Tables 8-1, 8-2, 8-3 — Helios manual) +# Each entry: bit_number -> (severity, description, comment) +# severity: 'C' = critical error, 'S' = status, 'I' = input error, '' = none +# --------------------------------------------------------------------------- +_LER_FLAGS = { + 0: ('C', 'Controller temperature failure (resonator/SHG/q-switch)', + 'Check CCE register for details'), + 1: ('S', 'Trigger input active', + 'High when trigger signal applied or laser in continuous pulsing'), + 2: ('I', 'Command error', + 'Unknown command sent to controller'), + 3: ('C', 'Laser disable pin open (utility connector)', + 'Shuts down pump diodes; reset LER 0 required to restart'), + 4: ('C', 'Internal hardware failure', + 'Contact Coherent'), + 5: ('C', 'Over voltage laser diode', + 'Check for open circuit or voltage spikes'), + 6: ('C', 'Internal hardware failure', + 'Contact Coherent'), + 7: ('C', 'Controller temperature failure at pump diodes', + 'Check LCE register for details'), + 8: ('S', 'Laser start delay (60 s warmup)', + 'Laser cannot be started yet; status error LED flashing'), + 9: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 10: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 11: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 12: ('S', 'Slave controller error (remote input)', + 'Valid only for master controller coupled with a slave'), + 13: ('', 'Laserhead not found', + 'Head not connected / not found; check EMI; ignore for double-electronic slave'), + 14: ('', 'Laserhead I\u00b2C acknowledge error', + 'Check environment for strong EMI; ignore for double-electronic slave'), + 15: ('S', 'Range-Error (not critical)', + 'Input value out of range'), +} + +_LCE_FLAGS = { + 0: ('C', 'Pump diode over/under temperature', + 'Limit exceeded (<10\u00b0C or >60\u00b0C); check head cooling'), + 1: ('C', 'Internal hardware failure', + 'Contact Coherent'), + 2: ('C', 'Pump diode temperature out of range', + 'Actual temp >2\u00b0C off setpoint for >1 min'), + 3: ('C', 'Pump diode current critical', + 'Current set too close to current limit'), + 4: ('C', 'Pump diode temperature out of limit', + 'Pump diode temperature is out of limit'), + 5: ('S', 'Door switch open', + 'Close utility connector pin 2 permanently to pin 9 (GND)'), + 7: ('C', 'Pump diode NTC error', + 'Invalid temperature measured or NTC broken'), + 8: ('C', 'Laser diode power stage over temperature', + 'Temp <10\u00b0C or >65\u00b0C at controller; check controller cooling'), + 9: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 10: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 11: ('C', 'Internal hardware failure', + 'Check environment for strong EMI; contact Coherent'), + 15: ('S', 'Range-Error (not critical)', + 'Input value out of range'), +} + +_CCE_FLAGS = { + 0: ('C', 'Resonator/SHG under/over temperature', + 'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'), + 1: ('C', 'Resonator/SHG NTC failure', + 'Temperature sensor broken or disconnected'), + 2: ('C', 'Resonator/SHG temperature out of range', + 'Actual temp >2\u00b0C off setpoint for >1 min'), + 3: ('C', 'Q-switch ADC / temperature readout failure', + 'Internal hardware error or no NTC connected'), + 4: ('C', 'Q-switch temperature out of range', + 'Actual temp >2\u00b0C off setpoint for >1 min'), + 5: ('C', 'Q-switch under/over temperature', + 'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'), + 7: ('C', 'Q-switch NTC failure', + 'Internal hardware error or no NTC connected'), + 8: ('C', 'Internal hardware failure', + 'Contact Coherent'), + 15: ('S', 'Range-Error (not critical)', + 'Input value out of range'), +} + +_SEVERITY_LABEL = {'C': '[CRIT]', 'S': '[STAT]', 'I': '[INPT]', '': '[INFO]'} + + +def _decode_register(flags_dict: dict, value: int) -> list: + """Return list of (bit, severity, description, comment) for each set bit.""" + active = [] + for bit, (sev, desc, comment) in flags_dict.items(): + if value & (1 << bit): + active.append((bit, sev, desc, comment)) + return active + + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class LaserWorker(QObject): + """Worker thread for laser operations to prevent UI blocking.""" + + # Signals + operation_complete = pyqtSignal(bool, str) # success, message + frequency_updated = pyqtSignal(int) + current_updated = pyqtSignal(int) + power_updated = pyqtSignal(float) + enabled_updated = pyqtSignal(bool) + serial_updated = pyqtSignal(str, str) # controller_sn, head_sn + status_registers_updated = pyqtSignal(object, object, object) # ler, lce, cce (int or None) + remote_enable_updated = pyqtSignal(object) # bool or None + raw_response_received = pyqtSignal(str, str) # command, response + + def __init__(self, laser: HeliosLaser): + super().__init__() + self.laser = laser + + def set_frequency(self, freq: int): + try: + success = self.laser.set_frequency_hz(freq) + msg = f"Frequency set to {freq} Hz" if success else "Failed to set frequency" + self.operation_complete.emit(success, msg) + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def set_current(self, current: int): + try: + success = self.laser.set_current_ma(current) + msg = f"Current set to {current} mA" if success else "Failed to set current" + self.operation_complete.emit(success, msg) + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def set_pulse_mode(self, mode: int): + try: + pulse_mode = PulseMode(mode) + success = self.laser.set_pulse_mode(pulse_mode) + msg = f"Pulse mode set to {pulse_mode.name}" if success else "Failed to set pulse mode" + self.operation_complete.emit(success, msg) + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def set_laser_enable(self, enable: bool): + try: + success = self.laser.set_laser_enable(enable) + state = "enabled" if enable else "disabled" + msg = f"Laser {state}" if success else f"Failed to {state} laser" + self.operation_complete.emit(success, msg) + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def query_frequency(self): + try: + freq = self.laser.get_frequency_hz() + if freq is not None: + self.frequency_updated.emit(freq) + self.operation_complete.emit(True, f"Frequency: {freq} Hz") + else: + self.operation_complete.emit(False, "Failed to query frequency") + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def query_current(self): + try: + current = self.laser.get_current_ma() + if current is not None: + self.current_updated.emit(current) + self.operation_complete.emit(True, f"Current: {current} mA") + else: + self.operation_complete.emit(False, "Failed to query current") + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def query_power(self): + try: + power = self.laser.get_power_mw() + if power is not None: + self.power_updated.emit(power) + self.operation_complete.emit(True, f"Power: {power:.2f} mW") + else: + self.operation_complete.emit(False, "Failed to query power") + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def query_enabled(self): + try: + enabled = self.laser.is_laser_enabled() + self.enabled_updated.emit(enabled) + state = "enabled" if enabled else "disabled" + self.operation_complete.emit(True, f"Laser is {state}") + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def query_serials(self): + try: + controller_sn = self.laser.get_controller_serial() + head_sn = self.laser.get_head_serial() + + if controller_sn and head_sn: + self.serial_updated.emit(controller_sn, head_sn) + msg = f"Controller: {controller_sn}, Head: {head_sn}" + self.operation_complete.emit(True, msg) + else: + self.operation_complete.emit(False, "Failed to query serial numbers") + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def query_status_registers(self): + try: + ler, lce, cce = self.laser.get_status_registers() + self.status_registers_updated.emit(ler, lce, cce) + self.operation_complete.emit(True, f"Status: LER={ler} LCE={lce} CCE={cce}") + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def query_remote_enable(self): + try: + state = self.laser.get_remote_enable() + self.remote_enable_updated.emit(state) + if state is not None: + self.operation_complete.emit(True, f"Remote enable (LRE): {'ON' if state else 'OFF'}") + else: + self.operation_complete.emit(False, "Failed to query remote enable") + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def set_remote_enable(self, enable: bool): + try: + success = self.laser.set_remote_enable(enable) + state = "ON" if enable else "OFF" + msg = f"Remote enable (LRE) set to {state}" if success else "Failed to set remote enable" + self.operation_complete.emit(success, msg) + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def do_reset_faults(self): + try: + success = self.laser.reset_faults() + msg = "Fault reset sequence sent (CCE 0 → LCE 0 → LER 0)" if success else "Failed to send reset sequence" + self.operation_complete.emit(success, msg) + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def do_ler_reset(self): + try: + success = self.laser._send_command("LER 0") + msg = "LER 0 sent" if success else "Failed to send LER 0" + self.operation_complete.emit(success, msg) + except Exception as e: + self.operation_complete.emit(False, str(e)) + + def send_raw(self, cmd: str): + try: + response = self.laser.send_raw_command(cmd) + if response is not None: + self.raw_response_received.emit(cmd, response) + self.operation_complete.emit(True, f"TX: {cmd!r} RX: {response!r}") + else: + self.operation_complete.emit(False, f"No response for: {cmd!r}") + except Exception as e: + self.operation_complete.emit(False, str(e)) + + +class HeliosTestApp(QMainWindow): + """Main application window for Helios laser testing.""" + + def __init__(self): + super().__init__() + self.laser = HeliosLaser() + self.worker = None + self.worker_thread = None + + self.init_ui() + self.update_port_list() + + def init_ui(self): + """Initialize the user interface.""" + self.setWindowTitle("Helios Laser Test Application") + self.setGeometry(100, 100, 900, 700) + + # Main widget and layout + main_widget = QWidget() + self.setCentralWidget(main_widget) + main_layout = QVBoxLayout(main_widget) + + # Connection group + connection_group = self.create_connection_group() + main_layout.addWidget(connection_group) + + # Tabs for control and monitoring + tabs = QTabWidget() + tabs.addTab(self.create_control_tab(), "Control") + tabs.addTab(self.create_monitor_tab(), "Monitor") + tabs.addTab(self.create_terminal_tab(), "Terminal") + main_layout.addWidget(tabs) + + # Status bar + self.statusBar().showMessage("Disconnected") + + def create_connection_group(self) -> QGroupBox: + """Create the connection control group.""" + group = QGroupBox("Connection") + layout = QHBoxLayout() + + # Port selection + layout.addWidget(QLabel("Serial Port:")) + self.combo_port = QComboBox() + self.combo_port.setMinimumWidth(150) + layout.addWidget(self.combo_port) + + # Refresh ports button + btn_refresh = QPushButton("Refresh Ports") + btn_refresh.clicked.connect(self.update_port_list) + layout.addWidget(btn_refresh) + + # Connect button + self.btn_connect = QPushButton("Connect") + self.btn_connect.clicked.connect(self.toggle_connection) + self.btn_connect.setMinimumWidth(100) + layout.addWidget(self.btn_connect) + + # Connection status + self.lbl_status = QLabel("Status: Disconnected") + font = self.lbl_status.font() + font.setBold(True) + self.lbl_status.setFont(font) + layout.addWidget(self.lbl_status) + + layout.addStretch() + group.setLayout(layout) + return group + + def create_control_tab(self) -> QWidget: + """Create the control tab.""" + widget = QWidget() + layout = QVBoxLayout(widget) + + # Frequency control + freq_group = QGroupBox("Frequency Control") + freq_layout = QHBoxLayout() + freq_layout.addWidget(QLabel("Frequency (Hz):")) + self.spin_frequency = QSpinBox() + self.spin_frequency.setRange(16700, 125000) + self.spin_frequency.setValue(50000) + self.spin_frequency.setSingleStep(1000) + freq_layout.addWidget(self.spin_frequency) + + btn_set_freq = QPushButton("Set Frequency") + btn_set_freq.clicked.connect(self.on_set_frequency) + btn_set_freq.setMaximumWidth(150) + freq_layout.addWidget(btn_set_freq) + + btn_get_freq = QPushButton("Query") + btn_get_freq.clicked.connect(self.on_query_frequency) + btn_get_freq.setMaximumWidth(100) + freq_layout.addWidget(btn_get_freq) + freq_layout.addStretch() + freq_group.setLayout(freq_layout) + layout.addWidget(freq_group) + + # Current control + current_group = QGroupBox("Current Control") + current_layout = QHBoxLayout() + current_layout.addWidget(QLabel("Current (mA):")) + self.spin_current = QSpinBox() + self.spin_current.setRange(0, 2000) + self.spin_current.setValue(1000) + self.spin_current.setSingleStep(100) + current_layout.addWidget(self.spin_current) + + btn_set_current = QPushButton("Set Current") + btn_set_current.clicked.connect(self.on_set_current) + btn_set_current.setMaximumWidth(150) + current_layout.addWidget(btn_set_current) + + btn_get_current = QPushButton("Query") + btn_get_current.clicked.connect(self.on_query_current) + btn_get_current.setMaximumWidth(100) + current_layout.addWidget(btn_get_current) + current_layout.addStretch() + current_group.setLayout(current_layout) + layout.addWidget(current_group) + + # Pulse mode control + mode_group = QGroupBox("Pulse Mode Control") + mode_layout = QHBoxLayout() + mode_layout.addWidget(QLabel("Pulse Mode:")) + self.combo_mode = QComboBox() + self.combo_mode.addItem("Single Pulse", PulseMode.SINGLE_PULSE.value) + self.combo_mode.addItem("Continuous Gating", PulseMode.CONTINUOUS_GATING.value) + self.combo_mode.addItem("Continuous Pulsing", PulseMode.CONTINUOUS_PULSING.value) + mode_layout.addWidget(self.combo_mode) + + btn_set_mode = QPushButton("Set Mode") + btn_set_mode.clicked.connect(self.on_set_mode) + btn_set_mode.setMaximumWidth(150) + mode_layout.addWidget(btn_set_mode) + mode_layout.addStretch() + mode_group.setLayout(mode_layout) + layout.addWidget(mode_group) + + # Laser enable/disable + enable_group = QGroupBox("Laser Control") + enable_layout = QHBoxLayout() + + self.btn_enable = QPushButton("Enable Laser") + self.btn_enable.setStyleSheet("background-color: lightgreen") + self.btn_enable.clicked.connect(self.on_enable_laser) + self.btn_enable.setMaximumWidth(150) + enable_layout.addWidget(self.btn_enable) + + self.btn_disable = QPushButton("Disable Laser") + self.btn_disable.setStyleSheet("background-color: lightcoral") + self.btn_disable.clicked.connect(self.on_disable_laser) + self.btn_disable.setMaximumWidth(150) + enable_layout.addWidget(self.btn_disable) + + btn_check_enabled = QPushButton("Query Status") + btn_check_enabled.clicked.connect(self.on_query_enabled) + btn_check_enabled.setMaximumWidth(150) + enable_layout.addWidget(btn_check_enabled) + + self.lbl_enabled = QLabel("Status: Unknown") + font = self.lbl_enabled.font() + font.setBold(True) + self.lbl_enabled.setFont(font) + enable_layout.addWidget(self.lbl_enabled) + enable_layout.addStretch() + enable_group.setLayout(enable_layout) + layout.addWidget(enable_group) + + # Status & Safety + safety_group = QGroupBox("Status && Safety") + safety_layout = QVBoxLayout() + + # Row 1: status registers + query/reset buttons + reg_row = QHBoxLayout() + + btn_query_status = QPushButton("Query Status") + btn_query_status.clicked.connect(self.on_query_status) + btn_query_status.setMaximumWidth(130) + reg_row.addWidget(btn_query_status) + + btn_reset_faults = QPushButton("Reset Faults") + btn_reset_faults.setStyleSheet("background-color: #FFD700") + btn_reset_faults.clicked.connect(self.on_reset_faults) + btn_reset_faults.setMaximumWidth(130) + reg_row.addWidget(btn_reset_faults) + + btn_ler_reset = QPushButton("LER 0 Reset") + btn_ler_reset.setStyleSheet("background-color: #FFA500") + btn_ler_reset.setToolTip("Send LER 0 only (clears controller error register)") + btn_ler_reset.clicked.connect(self.on_ler_reset) + btn_ler_reset.setMaximumWidth(130) + reg_row.addWidget(btn_ler_reset) + + reg_row.addSpacing(20) + reg_row.addWidget(QLabel("LER:")) + self.lbl_ler = QLabel("—") + bold_font = self.lbl_ler.font() + bold_font.setBold(True) + self.lbl_ler.setFont(bold_font) + reg_row.addWidget(self.lbl_ler) + + reg_row.addWidget(QLabel("LCE:")) + self.lbl_lce = QLabel("—") + self.lbl_lce.setFont(bold_font) + reg_row.addWidget(self.lbl_lce) + + reg_row.addWidget(QLabel("CCE:")) + self.lbl_cce = QLabel("—") + self.lbl_cce.setFont(bold_font) + reg_row.addWidget(self.lbl_cce) + + reg_row.addSpacing(20) + reg_row.addWidget(QLabel("Interlock:")) + self.lbl_interlock = QLabel("UNKNOWN") + self.lbl_interlock.setFont(bold_font) + self.lbl_interlock.setMinimumWidth(80) + reg_row.addWidget(self.lbl_interlock) + + reg_row.addStretch() + safety_layout.addLayout(reg_row) + + # Row 2: remote enable (LRE / pin 8) + lre_row = QHBoxLayout() + + btn_lre_on = QPushButton("Remote Enable ON") + btn_lre_on.setStyleSheet("background-color: lightgreen") + btn_lre_on.clicked.connect(lambda: self.on_set_remote_enable(True)) + btn_lre_on.setMaximumWidth(160) + lre_row.addWidget(btn_lre_on) + + btn_lre_off = QPushButton("Remote Enable OFF") + btn_lre_off.setStyleSheet("background-color: lightcoral") + btn_lre_off.clicked.connect(lambda: self.on_set_remote_enable(False)) + btn_lre_off.setMaximumWidth(160) + lre_row.addWidget(btn_lre_off) + + btn_lre_query = QPushButton("Query LRE") + btn_lre_query.clicked.connect(self.on_query_remote_enable) + btn_lre_query.setMaximumWidth(120) + lre_row.addWidget(btn_lre_query) + + lre_row.addSpacing(20) + lre_row.addWidget(QLabel("Enable Pin (LRE):")) + self.lbl_lre = QLabel("UNKNOWN") + self.lbl_lre.setFont(bold_font) + self.lbl_lre.setMinimumWidth(80) + lre_row.addWidget(self.lbl_lre) + + lre_row.addStretch() + safety_layout.addLayout(lre_row) + + # Row 3: decoded register flags + self.text_register_decode = QTextEdit() + self.text_register_decode.setReadOnly(True) + self.text_register_decode.setMinimumHeight(110) + self.text_register_decode.setMaximumHeight(160) + self.text_register_decode.setPlaceholderText( + "Register flags will appear here after querying status…" + ) + mono_font = QFont("Monospace") + mono_font.setStyleHint(QFont.StyleHint.Monospace) + self.text_register_decode.setFont(mono_font) + safety_layout.addWidget(self.text_register_decode) + + safety_group.setLayout(safety_layout) + layout.addWidget(safety_group) + + layout.addStretch() + return widget + + def create_monitor_tab(self) -> QWidget: + """Create the monitoring/information tab.""" + widget = QWidget() + layout = QVBoxLayout(widget) + + # System information + info_group = QGroupBox("System Information") + info_layout = QHBoxLayout() + + btn_query_all = QPushButton("Query All") + btn_query_all.clicked.connect(self.on_query_all) + info_layout.addWidget(btn_query_all) + + info_layout.addWidget(QLabel("Controller SN:")) + self.lbl_controller_sn = QLineEdit() + self.lbl_controller_sn.setReadOnly(True) + info_layout.addWidget(self.lbl_controller_sn) + + info_layout.addWidget(QLabel("Head SN:")) + self.lbl_head_sn = QLineEdit() + self.lbl_head_sn.setReadOnly(True) + info_layout.addWidget(self.lbl_head_sn) + info_group.setLayout(info_layout) + layout.addWidget(info_group) + + # Power monitoring + power_group = QGroupBox("Power Monitoring") + power_layout = QHBoxLayout() + + btn_get_power = QPushButton("Query Power") + btn_get_power.clicked.connect(self.on_query_power) + power_layout.addWidget(btn_get_power) + + power_layout.addWidget(QLabel("Output Power:")) + self.lbl_power = QLineEdit() + self.lbl_power.setReadOnly(True) + self.lbl_power.setMaximumWidth(150) + power_layout.addWidget(self.lbl_power) + + power_layout.addWidget(QLabel("mW")) + power_layout.addStretch() + power_group.setLayout(power_layout) + layout.addWidget(power_group) + + # Log/Message display + log_group = QGroupBox("Messages") + log_layout = QVBoxLayout() + self.text_log = QTextEdit() + self.text_log.setReadOnly(True) + self.text_log.setMaximumHeight(300) + log_layout.addWidget(self.text_log) + log_group.setLayout(log_layout) + layout.addWidget(log_group) + + layout.addStretch() + return widget + + def create_terminal_tab(self) -> QWidget: + """Create the raw command terminal tab.""" + widget = QWidget() + layout = QVBoxLayout(widget) + + # Input row + cmd_group = QGroupBox("Raw Command") + cmd_layout = QHBoxLayout() + + cmd_layout.addWidget(QLabel("Command:")) + self.le_raw_cmd = QLineEdit() + self.le_raw_cmd.setPlaceholderText("e.g. LDF or LDS 1000 or LER 0") + self.le_raw_cmd.returnPressed.connect(self.on_send_raw) + cmd_layout.addWidget(self.le_raw_cmd) + + btn_send_raw = QPushButton("Send") + btn_send_raw.setMaximumWidth(80) + btn_send_raw.clicked.connect(self.on_send_raw) + cmd_layout.addWidget(btn_send_raw) + + btn_clear_terminal = QPushButton("Clear") + btn_clear_terminal.setMaximumWidth(70) + btn_clear_terminal.clicked.connect(lambda: self.text_terminal.clear()) + cmd_layout.addWidget(btn_clear_terminal) + + cmd_group.setLayout(cmd_layout) + layout.addWidget(cmd_group) + + # Terminal output + term_group = QGroupBox("Response Log") + term_layout = QVBoxLayout() + self.text_terminal = QTextEdit() + self.text_terminal.setReadOnly(True) + mono_font = QFont("Monospace") + mono_font.setStyleHint(QFont.StyleHint.Monospace) + self.text_terminal.setFont(mono_font) + self.text_terminal.setPlaceholderText( + "TX/RX pairs will appear here.\n" + "Commands are sent as-is with a trailing CR.\n" + "No ? suffix needed — send the command name to query (e.g. LDF)." + ) + term_layout.addWidget(self.text_terminal) + term_group.setLayout(term_layout) + layout.addWidget(term_group) + + return widget + + def update_port_list(self): + """Update the available serial ports.""" + self.combo_port.clear() + try: + ports = HeliosLaser.list_available_ports() + if ports: + self.combo_port.addItems(ports) + else: + self.combo_port.addItem("No ports found") + except Exception as e: + self.combo_port.addItem(f"Error: {e}") + logger.error(f"Error listing ports: {e}") + + def toggle_connection(self): + """Toggle connection to the laser.""" + if self.laser.is_connected: + self.disconnect_laser() + else: + self.connect_laser() + + def connect_laser(self): + """Connect to the laser.""" + port = self.combo_port.currentText() + if not port or "No ports" in port or "Error" in port: + QMessageBox.warning(self, "Connection Error", "No valid port selected") + return + + if self.laser.connect(port): + self.lbl_status.setText(f"Status: Connected to {port}") + self.lbl_status.setStyleSheet("color: green") + self.btn_connect.setText("Disconnect") + self.combo_port.setEnabled(False) + + # Create worker for async operations + self.worker = LaserWorker(self.laser) + self.worker_thread = QThread() + self.worker.moveToThread(self.worker_thread) + self.worker.operation_complete.connect(self.on_operation_complete) + self.worker.frequency_updated.connect(self.on_frequency_updated) + self.worker.current_updated.connect(self.on_current_updated) + self.worker.power_updated.connect(self.on_power_updated) + self.worker.enabled_updated.connect(self.on_enabled_updated) + self.worker.serial_updated.connect(self.on_serial_updated) + self.worker.status_registers_updated.connect(self.on_status_registers_updated) + self.worker.remote_enable_updated.connect(self.on_remote_enable_updated) + self.worker.raw_response_received.connect(self.on_raw_response) + self.worker_thread.start() + + self.statusBar().showMessage(f"Connected to {port}") + logger.info(f"Connected to {port}") + else: + QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}") + + def disconnect_laser(self): + """Disconnect from the laser.""" + if self.worker_thread: + self.worker_thread.quit() + self.worker_thread.wait() + + self.laser.disconnect() + self.lbl_status.setText("Status: Disconnected") + self.lbl_status.setStyleSheet("color: red") + self.btn_connect.setText("Connect") + self.combo_port.setEnabled(True) + self.statusBar().showMessage("Disconnected") + logger.info("Disconnected from laser") + + def on_set_frequency(self): + """Set the laser frequency.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + freq = self.spin_frequency.value() + self.worker.set_frequency(freq) + + def on_query_frequency(self): + """Query the laser frequency.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + self.worker.query_frequency() + + def on_set_current(self): + """Set the laser current.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + current = self.spin_current.value() + self.worker.set_current(current) + + def on_query_current(self): + """Query the laser current.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + self.worker.query_current() + + def on_set_mode(self): + """Set the laser pulse mode.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + mode = self.combo_mode.currentData() + self.worker.set_pulse_mode(mode) + + def on_enable_laser(self): + """Enable the laser.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + self.worker.set_laser_enable(True) + + def on_disable_laser(self): + """Disable the laser.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + self.worker.set_laser_enable(False) + + def on_query_enabled(self): + """Query if laser is enabled.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + self.worker.query_enabled() + + def on_query_power(self): + """Query the laser output power.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + self.worker.query_power() + + def on_query_all(self): + """Query all laser parameters.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + + self.worker.query_serials() + self.worker.query_frequency() + self.worker.query_current() + self.worker.query_power() + self.worker.query_enabled() + self.worker.query_status_registers() + self.worker.query_remote_enable() + + def on_query_status(self): + """Query LER/LCE/CCE status registers.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + self.worker.query_status_registers() + + def on_reset_faults(self): + """Send the fault reset sequence.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + self.worker.do_reset_faults() + + def on_query_remote_enable(self): + """Query the remote enable (LRE) state.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + self.worker.query_remote_enable() + + def on_set_remote_enable(self, enable: bool): + """Set the remote enable (LRE) state.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + self.worker.set_remote_enable(enable) + + def on_ler_reset(self): + """Send LER 0 only.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + self.worker.do_ler_reset() + + def on_send_raw(self): + """Send the raw command from the terminal input.""" + if not self.laser.is_connected: + QMessageBox.warning(self, "Error", "Not connected to laser") + return + cmd = self.le_raw_cmd.text().strip() + if not cmd: + return + self.worker.send_raw(cmd) + + def on_raw_response(self, cmd: str, response: str): + """Display raw TX/RX pair in the terminal log.""" + self.text_terminal.append(f"TX: {cmd}") + self.text_terminal.append(f"RX: {response if response else ''}") + self.text_terminal.append("") + self.text_terminal.verticalScrollBar().setValue( + self.text_terminal.verticalScrollBar().maximum() + ) + + def on_operation_complete(self, success: bool, message: str): + """Handle operation completion.""" + self.log_message(message) + if not success: + QMessageBox.warning(self, "Operation Failed", message) + + def on_frequency_updated(self, freq: int): + """Update frequency display.""" + self.spin_frequency.setValue(freq) + + def on_current_updated(self, current: int): + """Update current display.""" + self.spin_current.setValue(current) + + def on_power_updated(self, power: float): + """Update power display.""" + self.lbl_power.setText(f"{power:.2f}") + + def on_enabled_updated(self, enabled: bool): + """Update enabled status display.""" + state = "Enabled" if enabled else "Disabled" + color = "green" if enabled else "red" + self.lbl_enabled.setText(f"Status: {state}") + self.lbl_enabled.setStyleSheet(f"color: {color}") + + def on_serial_updated(self, controller_sn: str, head_sn: str): + """Update serial number displays.""" + self.lbl_controller_sn.setText(controller_sn) + self.lbl_head_sn.setText(head_sn) + + def on_status_registers_updated(self, ler, lce, cce): + """Update status register displays, interlock indicator, and decoded flags.""" + def _fmt(val): + return str(val) if val is not None else "ERR" + + self.lbl_ler.setText(_fmt(ler)) + self.lbl_lce.setText(_fmt(lce)) + self.lbl_cce.setText(_fmt(cce)) + + # Interlock fault: any non-zero register value indicates an active fault + if any(v is None for v in (ler, lce, cce)): + self.lbl_interlock.setText("UNKNOWN") + self.lbl_interlock.setStyleSheet("color: gray") + elif any(v != 0 for v in (ler, lce, cce)): + self.lbl_interlock.setText("FAULT") + self.lbl_interlock.setStyleSheet("color: red; font-weight: bold") + else: + self.lbl_interlock.setText("OK") + self.lbl_interlock.setStyleSheet("color: green; font-weight: bold") + + # Decode and display individual flags + lines = [] + for reg_name, value, flags_dict in ( + ("LER", ler, _LER_FLAGS), + ("LCE", lce, _LCE_FLAGS), + ("CCE", cce, _CCE_FLAGS), + ): + if value is None: + lines.append(f"{reg_name}: ") + continue + active = _decode_register(flags_dict, value) + if not active: + lines.append(f"{reg_name} (raw={value}): OK — no flags set") + else: + lines.append(f"{reg_name} (raw={value}):") + for bit, sev, desc, comment in active: + label = _SEVERITY_LABEL.get(sev, '[ ]') + lines.append(f" {label} bit {bit:2d} ({1 << bit:>5}): {desc}") + lines.append(f" → {comment}") + self.text_register_decode.setPlainText("\n".join(lines)) + + def on_remote_enable_updated(self, state): + """Update remote enable (LRE) indicator.""" + if state is None: + self.lbl_lre.setText("UNKNOWN") + self.lbl_lre.setStyleSheet("color: gray") + elif state: + self.lbl_lre.setText("ACTIVE") + self.lbl_lre.setStyleSheet("color: green; font-weight: bold") + else: + self.lbl_lre.setText("INACTIVE") + self.lbl_lre.setStyleSheet("color: red; font-weight: bold") + + def log_message(self, message: str): + """Add message to log.""" + self.text_log.append(message) + # Auto-scroll to bottom + self.text_log.verticalScrollBar().setValue( + self.text_log.verticalScrollBar().maximum() + ) + + def closeEvent(self, event): + """Handle application close.""" + if self.laser.is_connected: + self.disconnect_laser() + if self.worker_thread and self.worker_thread.isRunning(): + self.worker_thread.quit() + self.worker_thread.wait() + event.accept() + + +def main(): + """Run the application.""" + app = QApplication(sys.argv) + window = HeliosTestApp() + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/left_off.md b/left_off.md new file mode 100644 index 0000000..0b31d73 --- /dev/null +++ b/left_off.md @@ -0,0 +1,55 @@ +# Session summary — scan timeout & geometry fixes + +## Bug fixed: oscilloscope timeout on first row + +**File:** `hardware/tektronix_base.py` — `transfer_fastframe()` (~line 1414) + +The scope was configured for N frames but only triggered on fewer (e.g. 57 of 58) because +the stage decelerates before the last laser pulse. `transfer_fastframe` looped using +`get_fastframe_count()` (the configured maximum), so the final `read_raw()` call blocked +waiting for data that never came and timed out. + +**Fix:** replaced `get_fastframe_count()` with a live query: +```python +acquired = int(self.query("ACQuire:NUMFRAMESACQuired?")) +``` +The loop now reads exactly as many frames as the scope actually captured. + +--- + +## Scan geometry overhaul + +**File:** `sc3_aui_app.py` + +### Constants added (near line 71) +```python +LASER_FREQ_HZ = 2000.0 # fixed laser pulse frequency +SCAN_RAMP_MM = v² / (2a) # ≈ 3.33 mm (100² / 2×1500) +``` +`SCAN_RAMP_MM` is the distance the stage needs to accelerate from rest to full scan +velocity, or decelerate back to rest. + +### `_run_scan()` changes +- `points_per_row` now uses `LASER_FREQ_HZ` instead of the param-supplied `laser_freq`. +- Pre-scan X position is `x_start - SCAN_RAMP_MM` so the stage arrives at `x_start` + already at full velocity (TRIGOUT_MAXV fires at the right place). +- Scan move ends at `x_start + x_delta + SCAN_RAMP_MM` so the stage doesn't begin + decelerating until after the last data point. + +### Travel-limit guards added (before any hardware interaction) +Raises `ValueError` with an actionable message if the extended move would exceed the +stage limits baked into the driver (`bbd20x.py`: X 0–110 mm, Y 0–75 mm): +- `x_start - SCAN_RAMP_MM < 0` +- `x_start + x_delta + SCAN_RAMP_MM > 110` +- any Y row position outside 0–75 mm + +Error messages tell the user exactly how many mm to adjust. + +--- + +## What to check / next steps +- Verify `ACQuire:NUMFRAMESACQuired?` is the correct query string for the specific scope + model in use (MDO/MSO series assumed; confirm against programmer manual). +- `LASER_FREQ_HZ = 2000.0` is a temporary constant — wire it to the UI param when ready. +- Confirm `SCAN_RAMP_MM` matches observed stage behaviour; if the BBD202 velocity profile + is not perfectly triangular the empirical ramp may differ slightly from v²/2a. diff --git a/motion_worker.py b/motion_worker.py new file mode 100644 index 0000000..56757a1 --- /dev/null +++ b/motion_worker.py @@ -0,0 +1,395 @@ +""" +Motion Controller Worker Thread + +Handles all motion control operations in a separate thread to keep the UI responsive. +Provides async command queueing and position updates via Qt signals. +""" + +from PyQt6 import QtCore +from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y +import queue +import time +from typing import Optional, Dict, Any + + +class MotionCommand: + """Represents a motion command""" + def __init__(self, cmd_type: str, **kwargs): + self.cmd_type = cmd_type + self.params = kwargs + + +class MotionWorker(QtCore.QObject): + """ + Worker object for handling motion control in a separate thread. + + Signals: + connected: Emitted when controller connects successfully + disconnected: Emitted when controller disconnects + connection_failed: Emitted when connection fails (error_msg: str) + position_updated: Emitted when position changes (x: float, y: float) + homed_status: Emitted with home status (x_homed: bool, y_homed: bool) + move_completed: Emitted when a move completes (axis: str) + error_occurred: Emitted when an error occurs (error_msg: str) + """ + + # Signals + connected = QtCore.pyqtSignal() + disconnected = QtCore.pyqtSignal() + connection_failed = QtCore.pyqtSignal(str) + position_updated = QtCore.pyqtSignal(float, float) # x, y in mm + homed_status = QtCore.pyqtSignal(bool, bool) # x_homed, y_homed + motion_status = QtCore.pyqtSignal(bool, bool) # x_moving, y_moving + move_completed = QtCore.pyqtSignal(str) # axis name + error_occurred = QtCore.pyqtSignal(str) # error message + + def __init__(self): + super().__init__() + self.controller: Optional[ThorlabsServoDriver] = None + self.is_connected = False + self.command_queue = queue.Queue() + self.running = True + + # Default parameters + self.jog_speed = 20.0 # mm/s + self.acceleration = 50.0 # mm/s^2 + self.step_size = 1.0 # mm + + # Position tracking + self.last_x = None + self.last_y = None + + # Status tracking + self.last_x_homed = None + self.last_y_homed = None + self.last_x_moving = None + self.last_y_moving = None + + # Position update throttling + self.last_position_update_time = 0 + self.position_update_interval = 0.2 # seconds between position reads + + # Flag to pause polling during scanning (scan worker handles its own position queries) + self.scanning_active = False + + @QtCore.pyqtSlot() + def run(self): + """Main worker loop - processes commands from queue""" + print("Motion worker thread started") + + while self.running: + try: + # Check for commands with timeout to allow periodic position updates + try: + cmd = self.command_queue.get(timeout=0.05) # 50ms timeout + self.process_command(cmd) + except queue.Empty: + pass + + # Periodically update position and status if connected + # Skip updates during scanning - scan worker handles its own position queries + if self.is_connected and self.controller and not self.scanning_active: + self.update_position() + self.update_home_status() + self.update_motion_status() + + except Exception as e: + print(f"Error in motion worker loop: {e}") + self.error_occurred.emit(str(e)) + + # Cleanup on exit + if self.controller: + try: + self.controller.disconnect() + except: + pass + + print("Motion worker thread stopped") + + def process_command(self, cmd: MotionCommand): + """Process a motion command""" + try: + if cmd.cmd_type == 'connect': + self.do_connect() + elif cmd.cmd_type == 'disconnect': + self.do_disconnect() + elif cmd.cmd_type == 'jog': + self.do_jog(cmd.params['axis'], cmd.params['direction']) + elif cmd.cmd_type == 'home': + self.do_home(cmd.params['axis']) + elif cmd.cmd_type == 'set_velocity': + self.do_set_velocity(cmd.params['speed'], cmd.params['accel']) + elif cmd.cmd_type == 'set_step_size': + self.step_size = cmd.params['step_size'] + elif cmd.cmd_type == 'set_axis_enable': + self.do_set_axis_enable(cmd.params['axis'], cmd.params['enabled']) + elif cmd.cmd_type == 'stop': + self.running = False + + except Exception as e: + print(f"Error processing command {cmd.cmd_type}: {e}") + self.error_occurred.emit(f"Command '{cmd.cmd_type}' failed: {str(e)}") + + def do_connect(self): + """Connect to the motion controller""" + try: + self.controller = ThorlabsServoDriver() + self.controller.connect() + + # Enable channels + self.controller.enable_axis(AXIS_X) + self.controller.enable_axis(AXIS_Y) + + # Start polling to populate cached state (positions, homed, moving, errors) + self.controller.start_polling(interval=0.2) + + # Wait for first polling cycle to populate status + time.sleep(0.3) + + # Set initial velocity parameters + for dest in [AXIS_X, AXIS_Y]: + self.controller.set_velocity_params( + dest, + max_velocity=self.jog_speed, + acceleration=self.acceleration + ) + + self.is_connected = True + # Force initial updates (they will be emitted because last values are None) + self.update_position() + self.update_home_status() + self.update_motion_status() + self.connected.emit() + + print("Motion controller connected successfully") + + except Exception as e: + print(f"Failed to connect to motion controller: {e}") + self.connection_failed.emit(str(e)) + + def do_disconnect(self): + """Disconnect from the motion controller""" + if self.controller: + try: + self.controller.disconnect() + print("Motion controller disconnected") + except Exception as e: + print(f"Error during disconnect: {e}") + + self.controller = None + self.is_connected = False + self.disconnected.emit() + + def do_jog(self, axis: str, direction: int): + """Execute a jog move""" + if not self.is_connected or not self.controller: + return + + try: + dest = AXIS_X if axis == 'x' else AXIS_Y + + # Calculate relative distance + distance = self.step_size * direction + + # Execute the move (blocking, with short timeout for continuous jogging) + self.controller.move_axis_relative(dest, distance, timeout=0.5) + + # Update position + self.update_position() + + self.move_completed.emit(axis) + + except TimeoutError: + # Timeout is expected during continuous jog - don't report as error + pass + except Exception as e: + print(f"Jog error: {e}") + self.error_occurred.emit(f"Jog failed: {str(e)}") + + def do_home(self, axis: str): + """Home an axis""" + if not self.is_connected or not self.controller: + return + + try: + dest = AXIS_X if axis == 'x' else AXIS_Y + + print(f"Homing {axis.upper()} axis...") + self.controller.home_axis(dest, timeout=60.0) + + # Update position and status after homing + self.update_position() + self.update_home_status() + + print(f"{axis.upper()} axis homed successfully") + + except TimeoutError: + print(f"Home timeout: {axis.upper()} axis") + self.error_occurred.emit(f"Homing {axis.upper()} timed out") + except Exception as e: + print(f"Home error: {e}") + self.error_occurred.emit(f"Homing {axis.upper()} failed: {str(e)}") + + def do_set_velocity(self, speed: float, accel: float): + """Set velocity parameters""" + if not self.is_connected or not self.controller: + self.jog_speed = speed + self.acceleration = accel + return + + try: + self.jog_speed = speed + self.acceleration = accel + + for dest in [AXIS_X, AXIS_Y]: + self.controller.set_velocity_params( + dest, + max_velocity=self.jog_speed, + acceleration=self.acceleration + ) + + except Exception as e: + print(f"Set velocity error: {e}") + + def do_set_axis_enable(self, axis: str, enabled: bool): + """Enable or disable an axis for manual movement""" + if not self.is_connected or not self.controller: + return + + try: + dest = AXIS_X if axis == 'x' else AXIS_Y + if enabled: + self.controller.enable_axis(dest) + else: + self.controller.disable_axis(dest) + state_str = "enabled" if enabled else "disabled" + print(f"{axis.upper()} axis {state_str}") + + except Exception as e: + print(f"Set axis enable error: {e}") + self.error_occurred.emit(f"Failed to {'enable' if enabled else 'disable'} {axis.upper()} axis: {str(e)}") + + def update_position(self): + """Update current position and emit signal if changed""" + if not self.is_connected or not self.controller: + return + + # Throttle position reads to avoid excessive signal emission + current_time = time.time() + if current_time - self.last_position_update_time < self.position_update_interval: + return + self.last_position_update_time = current_time + + try: + # Read cached positions (populated by polling worker) + x_pos = self.controller.positions[0] + y_pos = self.controller.positions[1] + + # Always emit on first update, or if position changed significantly (> 0.001mm) + if (self.last_x is None or self.last_y is None or + abs(x_pos - self.last_x) > 0.001 or abs(y_pos - self.last_y) > 0.001): + self.last_x = x_pos + self.last_y = y_pos + print(f"Position update: X={x_pos:.3f}mm, Y={y_pos:.3f}mm") + self.position_updated.emit(x_pos, y_pos) + + except Exception as e: + print(f"Error updating position: {e}") + import traceback + traceback.print_exc() + + def update_home_status(self): + """Update home status and emit signal if changed""" + if not self.is_connected or not self.controller: + return + + try: + x_homed = self.controller.am_homed[0] + y_homed = self.controller.am_homed[1] + + # Only emit if status changed + if x_homed != self.last_x_homed or y_homed != self.last_y_homed: + self.last_x_homed = x_homed + self.last_y_homed = y_homed + self.homed_status.emit(x_homed, y_homed) + + except Exception as e: + print(f"Error updating home status: {e}") + import traceback + traceback.print_exc() + + def update_motion_status(self): + """Update motion status and emit signal if changed. + + The new driver's polling worker keeps am_moving[], am_error[] + up to date automatically via status update messages. + """ + if not self.is_connected or not self.controller: + return + + try: + # Check for any error conditions + if self.controller.am_error[0]: + self.error_occurred.emit("X-axis error detected") + if self.controller.am_error[1]: + self.error_occurred.emit("Y-axis error detected") + + # Read cached motion status (updated by polling worker) + x_moving = self.controller.am_moving[0] + y_moving = self.controller.am_moving[1] + + # Only emit if status changed + if x_moving != self.last_x_moving or y_moving != self.last_y_moving: + self.last_x_moving = x_moving + self.last_y_moving = y_moving + self.motion_status.emit(x_moving, y_moving) + + except Exception as e: + print(f"Error updating motion status: {e}") + import traceback + traceback.print_exc() + + # Slot methods for queuing commands + @QtCore.pyqtSlot() + def queue_connect(self): + """Queue a connect command""" + self.command_queue.put(MotionCommand('connect')) + + @QtCore.pyqtSlot() + def queue_disconnect(self): + """Queue a disconnect command""" + self.command_queue.put(MotionCommand('disconnect')) + + @QtCore.pyqtSlot(str, int) + def queue_jog(self, axis: str, direction: int): + """Queue a jog command""" + self.command_queue.put(MotionCommand('jog', axis=axis, direction=direction)) + + @QtCore.pyqtSlot(str) + def queue_home(self, axis: str): + """Queue a home command""" + self.command_queue.put(MotionCommand('home', axis=axis)) + + @QtCore.pyqtSlot(float, float) + def queue_set_velocity(self, speed: float, accel: float): + """Queue a set velocity command""" + self.command_queue.put(MotionCommand('set_velocity', speed=speed, accel=accel)) + + @QtCore.pyqtSlot(float) + def queue_set_step_size(self, step_size: float): + """Queue a set step size command""" + self.command_queue.put(MotionCommand('set_step_size', step_size=step_size)) + + @QtCore.pyqtSlot(str, bool) + def queue_set_axis_enable(self, axis: str, enabled: bool): + """Queue a command to enable or disable an axis""" + self.command_queue.put(MotionCommand('set_axis_enable', axis=axis, enabled=enabled)) + + @QtCore.pyqtSlot() + def stop(self): + """Stop the worker thread""" + # Set running to False immediately so the main loop can exit + # even if it's blocked waiting for a response from the controller + self.running = False + # Also queue a stop command to ensure the command_queue.get() returns + self.command_queue.put(MotionCommand('stop')) diff --git a/run_helios_test.sh b/run_helios_test.sh new file mode 100755 index 0000000..6969309 --- /dev/null +++ b/run_helios_test.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Launcher script for Helios laser test application + +source /opt/srasenv/bin/activate +cd /opt/scanengine-3 +python3 helios_test_app.py diff --git a/sc3-aui-camera.ui b/sc3-aui-camera.ui new file mode 100644 index 0000000..fc89452 --- /dev/null +++ b/sc3-aui-camera.ui @@ -0,0 +1,76 @@ + + + Form + + + + 0 + 0 + 654 + 754 + + + + Form + + + + + + + + + 640 + 640 + + + + + 640 + 640 + + + + + + + + Exposure: + + + + + + + Qt::Orientation::Horizontal + + + + + + + Gain: + + + + + + + Qt::Orientation::Horizontal + + + + + + + Close Window + + + + + + + + + + diff --git a/sc3-aui-main.ui b/sc3-aui-main.ui new file mode 100644 index 0000000..ff26821 --- /dev/null +++ b/sc3-aui-main.ui @@ -0,0 +1,963 @@ + + + MainWindow + + + + 0 + 0 + 1016 + 1184 + + + + MainWindow + + + + + + + + + + Noto Sans Condensed Medium + 36 + + + + Scanengine3 - AUI + + + + + + + + Noto Sans Condensed SemiBold + 16 + + + + Geometry Data + + + + + + + + + Scan Save Directory: + + + + + + + + 150 + 16777215 + + + + + + + + + 150 + 16777215 + + + + + + + + YS: + + + + + + + + 150 + 16777215 + + + + + + + + YD: + + + + + + + XD: + + + + + + + + 150 + 16777215 + + + + + + + + Scan File Prefix: + + + + + + + NumAngles: + + + + + + + XS: + + + + + + + + 350 + 16777215 + + + + + + + + + 150 + 16777215 + + + + + + + + RowSpacing: + + + + + + + + 150 + 16777215 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 350 + 16777215 + + + + + + + + Browse + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + Noto Sans Condensed SemiBold + 16 + + + + Communication Settings: + + + + + + + + + Connect + + + true + + + + + + + + + + + + + Connect + + + true + + + + + + + T3R Port: + + + + + + + Connect + + + true + + + + + + + BBD202 Port: + + + + + + + Oscilloscope IP Address: + + + + + + + + + + + + + Noto Sans Condensed SemiBold + 16 + + + + T3RSL Manual Controls: + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Enable T1 + + + true + + + + + + + + 150 + 16777215 + + + + Enable T2 + + + true + + + + + + + Enable T3 + + + true + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + T1 Axis + + + + + + + T2 Axis + + + + + + + T3 Axis + + + + + + + Up + + + + + + + + 150 + 16777215 + + + + Up + + + + + + + Up + + + + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + + + + 100 + 16777215 + + + + Jog Speed: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 75 + 16777215 + + + + + + + + + + Down + + + + + + + + 150 + 16777215 + + + + Down + + + + + + + Down + + + + + + + CCW + + + + + + + GR Axis + + + Qt::AlignmentFlag::AlignCenter + + + + + + + CW + + + + + + + Enable GR + + + true + + + + + + + + + + + Motor Current (mA): + + + + + + + T1 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + 0 + + + 2000 + + + 600 + + + + + + + Set T1 + + + + + + + T2 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + 0 + + + 2000 + + + 600 + + + + + + + Set T2 + + + + + + + T3 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + 0 + + + 2000 + + + 600 + + + + + + + Set T3 + + + + + + + GR + + + Qt::AlignmentFlag::AlignCenter + + + + + + + 0 + + + 2000 + + + 600 + + + + + + + Set GR + + + + + + + + + + Noto Sans Condensed SemiBold + 16 + + + + MLS203-1 Manual Controls: + + + + + + + + + Toggle Axes Enable + + + + + + + + + + 100 + 16777215 + + + + Jog Speed: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 75 + 16777215 + + + + + + + + + + + 100 + 16777215 + + + + + Noto Sans Condensed SemiBold + 24 + + + + 000.00 + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Home All Axes + + + + + + + + 150 + 16777215 + + + + Y+ + + + + + + + + 100 + 16777215 + + + + X Position: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 100 + 16777215 + + + + + Noto Sans Condensed SemiBold + 24 + + + + 000.00 + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 150 + 16777215 + + + + X- + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 150 + 16777215 + + + + Y- + + + + + + + + 150 + 16777215 + + + + X+ + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 100 + 16777215 + + + + Y Position: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Set Current Coords as Start Coords + + + + + + + Calculate Delta (Current - Start) + + + + + + + + + Show Camera Window + + + true + + + + + + + Start Scanning + + + + + + + + + + x_start_edit + y_start_edit + x_delta_edit + y_delta_edit + num_angles_edit + row_spacing_edit + scan_prefix_edit + scan_save_dir_edit + save_dir_browse_btn + t3r_comport_edit + t3r_connect_toggle + bbd202_comport_edit + bbd202_connect_toggle + oscope_ip_edit + oscope_connect_toggle + t3r_enable_t1_btn + t3r_enable_t2_btn + t3r_enable_t3_btn + jog_t1_up_btn + jog_t2_up_btn + jog_t3_up_btn + t3r_jog_speed_edit + jog_t1_down_btn + jog_t2_down_btn + jog_t3_down_btn + jog_gr_ccw_btn + jog_gr_cw_btn + t3r_enable_gr_btn + t3r_current_t1_spin + t3r_set_current_t1_btn + t3r_current_t2_spin + t3r_set_current_t2_btn + t3r_current_t3_spin + t3r_set_current_t3_btn + t3r_current_gr_spin + t3r_set_current_gr_btn + bbd_enable_all_btn + lineEdit_10 + bbd_home_all_btn + bbd_jog_y_pos_btn + bbd_jog_x_neg_btn + bbd_jog_y_neg_btn + bbd_jog_x_pos_btn + bbd_set_current_start_btn + bbd_set_delta_current_btn + show_camera_toggle + start_scan_btn + + + + diff --git a/sc3-aui-scanprogress.ui b/sc3-aui-scanprogress.ui new file mode 100644 index 0000000..74c3196 --- /dev/null +++ b/sc3-aui-scanprogress.ui @@ -0,0 +1,153 @@ + + + Form + + + + 0 + 0 + 867 + 388 + + + + Form + + + + + + + + + Noto Sans Condensed SemiBold + 48 + + + + SCANNING + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + Noto Sans Condensed SemiBold + 14 + + + + Current Scan Progress: + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter + + + + + + + + Noto Sans Condensed SemiBold + 12 + + + + Row 0 of 999 + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + 24 + + + + + + + + + + Noto Sans Condensed SemiBold + 14 + + + + Overall Scan Progress: + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter + + + + + + + + Noto Sans Condensed SemiBold + 12 + + + + Angle 0 of 18 + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + 24 + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + Noto Sans Condensed ExtraBold + 36 + + + + ABORT + + + + + + + + + + diff --git a/sc3-new.ui b/sc3-new.ui new file mode 100644 index 0000000..ed19d9e --- /dev/null +++ b/sc3-new.ui @@ -0,0 +1,2635 @@ + + + MainWindow + + + + 0 + 0 + 1312 + 1036 + + + + MainWindow + + + QMainWindow { + background-color: #000; + color: #FFF +} +QWidget { + background-color: #000; + color: #FFF; + font-family: "Space Grotesk", sans-serif +} +QLabel { + color: #FFF +} +QTabWidget { + border: 2px solid #696773; +} + + + + + + + + 1 + + + + + + + + + + Space Grotesk + 48 + + + + Scanengine + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + + Space Grotesk + 14 + + + + What do you want to do? + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Start a New Scan + + + + + + + Resume an Interrupted Scan + + + + + + + Edit Options + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + + + Space Grotesk + 32 + + + + Scanengine Configuration + + + + + + + + Space Grotesk + 14 + + + + 2 + + + + Kinematics + + + + + + + Space Grotesk + 14 + + + + Scan Kinematics: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Y +Coordinate: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + X +Coordinate: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Trigger Behavior: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Scan Acceleration: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + 0 x 0 micron + + + + + + + Scan Velocity: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + Space Grotesk + true + false + true + + + + This is affected by settings in the +Generation/IR and PulseDecimator panels as well. + + + + + + + + + + + Space Grotesk + 14 + + + + Optical Axis Correction + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + Serial Number: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + mm/s2 + + + + + + + Test Connection + + + + + + + + + + Effective Pixel +Size: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + mm/s + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + Space Grotesk + 14 + + + + Triggering Settings + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + + + + Detection / VIS + + + + + + + + Interlock Status: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Diode Status: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + 123456789 + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Test Connection && Query Laser + + + + + + + Keyswitch Status: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Default +Power Level: + + + + + + + Serial Number: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + milliwatts + + + + + + + + 200 + 16777215 + + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + 0.00C + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Temperature: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + Generation / IR + + + + + + + + Interlock Status: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + + 400 + 16777215 + + + + + + + + Diode Pump +Current: + + + + + + + Serial Port: + + + + + + + Refresh Ports + + + + + + + milliamps + + + + + + + Diode Status: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Enable Switch Status: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Warmup Lockout: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Connect + + + + + + + + + + Diode Temperature: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Pulse Frequency: + + + + + + + Head Temperature: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + + 400 + 16777215 + + + + + + + + + Space Grotesk + 22 + + + + Status Information: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + Hertz + + + + + + + Head Hours: + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Unknown + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + Pixel Size: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + 0 x 0 microns + + + + + + + + Space Grotesk + true + true + + + + QFrame::Shape::NoFrame + + + This is also affected by settings in the Kinematics +and PulseDecimator tabs. + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + PulseDecimator + + + + + + + + This is also affected by settings on the +Kinematics and Generation tabs. + + + + + + + Current Divider Value + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Unknown + + + + + + + Unknown + + + + + + + + + + + 150 + 16777215 + + + + + + + + Refresh Ports + + + + + + + Unknown + + + + + + + Divider Value: + + + + + + + Firmware Version + + + + + + + T3RSL +Communicaton Port: + + + + + + + Enable RowPack feature? + + + + + + + Supports RowPack? + + + + + + + Effective Pixel Size: + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Connect && Query + + + + + + + 0 x 0 microns + + + + + + + + + + T3R-SL + + + + + + + + T Axis Microstepping Mode: + + + + + + + Connect && Query + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 150 + 16777215 + + + + + + + + T3RSL Communication Port: + + + + + + + + 200 + 16777215 + + + + + + + + GR Axis Drive Current + + + + + + + + 150 + 16777215 + + + + Refresh Ports + + + + + + + GR Axis Microstepping Mode: + + + + + + + T Axis Drive Current + + + + + + + + 200 + 16777215 + + + + + + + + + 150 + 16777215 + + + + + + + + milliamperes + + + + + + + + 350 + 0 + + + + + + + + milliamperes + + + + + + + + + + Oscilloscope + + + + + + + + Oscilloscope IP Address: + + + + + + + + + + Test Connection + + + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Save Settings + + + + + + + Cancel + + + + + + + + + + + + + + + + + + Space Grotesk + 32 + + + + Start a New Scan + + + + + + + + + VIS Laser: + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + X-Position + + + + + + + + Space Grotesk + 28 + + + + 000.00 + + + + + + + Y-Position + + + + + + + + Space Grotesk + 28 + + + + 000.00 + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Set as Start + + + + + + + Calculate Delta + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + 16777213 + 16777215 + + + + Scan Friendly Name: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + 50 microns + + + + + + + + 16777213 + 16777215 + + + + Save to Folder: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + Space Grotesk + 24 + + + + Sample Check: + + + + + + + + 480 + 480 + + + + + 480 + 480 + + + + QWidget { + border: 1px solid #FFF +} + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 16777213 + 16777215 + + + + X Delta: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 16777213 + 16777215 + + + + Row Size: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 16777213 + 16777215 + + + + Y Delta: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 16777213 + 16777215 + + + + Start Y +Coord: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + 150 + 16777215 + + + + Toggle Emission + + + + + + + + 16777215 + 16777215 + + + + Test Power Level: + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 50 + 16777215 + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + 100 + 16777215 + + + + + + + + 100 microns + + + + + + + + 100 + 16777215 + + + + + + + + Stage Jog Controls: + + + + + + + + 100 + 16777215 + + + + + + + + + 480 + 16777215 + + + + Camera Controls: + + + + + + + + 16777213 + 16777215 + + + + Start X +Coord: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 16777213 + 16777215 + + + + Number of +Angles: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + + 150 + 16777215 + + + + Browse Folders + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Exposure: + + + + + + + Qt::Orientation::Horizontal + + + + + + + Gain: + + + + + + + Qt::Orientation::Horizontal + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + X - + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Y + + + + + + + + Jog Speed [mm/s]: + + + + + + + + 75 + 16777215 + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Y - + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + X + + + + + + + + + + + + + 480 + 16777215 + + + + Camera Preview: + + + + + + + + 16777213 + 16777215 + + + + File Prefix: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 400 + 0 + + + + + + + + + 100 + 16777215 + + + + + + + + + 100 + 16777215 + + + + + + + + 250 microns + + + + + + + + + + + Continue + + + + + + + + + + + + + + Space Grotesk + 20 + + + + Metadata Summary: + + + + + + + Y-Start: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 150 + 0 + + + + Pixel Size: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Select Existing Scan Directory: + + + + + + + Unknown + + + + + + + + + + Unknown + + + + + + + Unknown + + + + + + + 0 x 0 microns + + + + + + + Number of Angles: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Friendly Name: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 150 + 16777215 + + + + Browse Directory + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + Space Grotesk + 32 + + + + Resume an Existing Scan: + + + + + + + + 150 + 0 + + + + Y-Delta: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Unknown + + + + + + + 0 + + + + + + + Unknown + + + + + + + Last Successfully +Scanned Angle: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Unknown + + + + + + + + 150 + 0 + + + + X-Delta: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + X-Start: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Expanding + + + + 20 + 40 + + + + + + + + Read Directory and Load Metadata + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + Resume Scanning + + + + + + + + + + + + + 24 + + + + + + + + + Kinematics Info: + + + + + + + + + XPos: + + + + + + + 000.00 + + + + + + + + + + + YPos: + + + + + + + 000.00 + + + + + + + + + + + State: + + + + + + + IDLE + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Genesis Info: + + + + + + + + + State: + + + + + + + EMISSION + + + + + + + + + + + Power: + + + + + + + 000mW + + + + + + + + + + + Temp: + + + + + + + 0C + + + + + + + + + + + Error: + + + + + + + NONE + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Helios Info: + + + + + + + + + State: + + + + + + + EMISSION + + + + + + + + + + + Frequency: + + + + + + + 20,000Hz + + + + + + + + + + + Pump Current: + + + + + + + 1500mA + + + + + + + + + + + Head Hours: + + + + + + + 0.0 + + + + + + + + + + + Head Temp: + + + + + + + 0C + + + + + + + + + + + Error: + + + + + + + NONE + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + PulseDecimator: + + + + + + + + + Status: + + + + + + + ACTIVE + + + + + + + + + + + Divider: + + + + + + + /10 + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + Space Grotesk + 32 + + + + Scanning in Progress: + + + + + + + Scan Preview (DC Only): + + + + + + + 0h 0min + + + + + + + Scan Progress: + + + + + + + Overall Progress: + + + + + + + Estimated Remaining Time: + + + + + + + 24 + + + + + + + + 640 + 640 + + + + + 640 + 640 + + + + QWidget { + border: 1px solid #FFF; +} + + + + + + + + + + Space Grotesk + 14 + DemiBold + + + + ABORT SCAN + + + + + + + + + + + + + + diff --git a/sc3_aui_app.py b/sc3_aui_app.py new file mode 100755 index 0000000..6d18b3f --- /dev/null +++ b/sc3_aui_app.py @@ -0,0 +1,1500 @@ +#!/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 json +import math +import queue +import struct +import sys +import time +from pathlib import Path +import threading +from threading import Thread + +import serial +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, QFileDialog, QLabel, QMainWindow, QMessageBox, QWidget, +) + +ROOT = Path(__file__).parent +sys.path.insert(0, str(ROOT)) + +from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver +from hardware.tektronix_base import TektronixOscilloscopeBase +from hardware.uc480_camera import CameraStreamThread, UC480Camera + +# ── Config defaults ────────────────────────────────────────────────────────── + +_AUI_DEFAULTS_PATH = ROOT / "aui_defaults.json" +_AUI_DEFAULTS_FALLBACK = { + "t3r_port": "/dev/ttyUSB0", + "bbd_port": "/dev/ttyUSB1", + "oscope_ip": "192.168.0.1", + "laser_freq_hz": 2000, + "save_dir": str(ROOT / "scans"), +} + +def _load_aui_defaults() -> dict: + if _AUI_DEFAULTS_PATH.exists(): + try: + with open(_AUI_DEFAULTS_PATH) as f: + return {**_AUI_DEFAULTS_FALLBACK, **json.load(f)} + except Exception: + pass + # File absent or unreadable — write fresh copy and return fallback + _save_aui_defaults(_AUI_DEFAULTS_FALLBACK) + return dict(_AUI_DEFAULTS_FALLBACK) + +def _save_aui_defaults(d: dict) -> None: + try: + with open(_AUI_DEFAULTS_PATH, "w") as f: + json.dump(d, f, indent=2) + except Exception as e: + print(f"[AUI] Could not save defaults: {e}") + +_aui = _load_aui_defaults() + +DEFAULT_T3R_PORT = _aui["t3r_port"] +DEFAULT_BBD_PORT = _aui["bbd_port"] +DEFAULT_OSCOPE_IP = _aui["oscope_ip"] +DEFAULT_LASER_FREQ_HZ = float(_aui["laser_freq_hz"]) +DEFAULT_SAVE_DIR = _aui["save_dir"] + +# ── 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 +T3R_BAUD = 115200 + +# GR axis: CALIBRATE these two constants to your mechanical setup +GR_STEPS_PER_DEGREE = 100 # TMC2130 microsteps per degree of GR rotation +GR_MOVE_SPEED_HZ = 2000 # steps/sec for inter-angle GR moves +GR_JOG_STEPS = 200 # steps per manual jog press +T1T2T3_JOG_STEPS = 500 # steps per manual jog press for T1/T2/T3 + +_T3R_AXIS_NAMES = {1: "T1", 2: "T2", 3: "T3", 4: "GR"} + +BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202 +BBD_JOG_SPEED_MM_S = 10.0 +BBD_JOG_ACCEL_MM_S2 = 50.0 + +# ── Binary blob format ─────────────────────────────────────────────────────── +# Full spec: scan_format.md +BLOB_MAGIC = b"SRAS" +BLOB_VERSION = 4 +SCAN_CHANNELS = [1, 3, 4] # oscilloscope channels recorded, in order +# ">4s B H H f f f f I I d B B" +# magic ver n_angles n_rows xs xd vel freq nf spf sr bps n_channels +BLOB_HDR_FMT = ">4sBHHffffIIdBB" + + +def _open_scan_file(path: Path, n_angles: int, n_rows: int, + x_start: float, x_delta: float, + velocity: float, laser_freq: float, + n_frames: int, samples_per_frame: int, + sample_rate: float, + angles: list[float], y_positions: list[float], + preambles: list[str], + background_waveform: bytes): + """Create a new SRAS file and write the global header + angle/row tables. + + Returns an open binary file object positioned at the start of the data + block. The caller must close it (use in a try/finally block). + + Data is written by appending waveforms in angle-major, row-minor, + channel-inner order: for each angle, for each row, for each channel in + SCAN_CHANNELS order, all n_frames waveforms are written sequentially. + + preambles: one WFMOutpre string per channel (same order as SCAN_CHANNELS), + written as length-prefixed UTF-8 blocks after the row table. + + background_waveform: raw int8 bytes of a 64-sample averaged CH1 waveform + captured with the Helios laser enabled and Genesis laser + disabled, written as uint32 length prefix followed by + the data. + """ + path.parent.mkdir(parents=True, exist_ok=True) + f = open(path, "wb") + header = struct.pack( + BLOB_HDR_FMT, + BLOB_MAGIC, BLOB_VERSION, + n_angles, n_rows, + x_start, x_delta, + velocity, laser_freq, + n_frames, samples_per_frame, + sample_rate, + 1, # bytes_per_sample: int8 from scope default + len(SCAN_CHANNELS), # n_channels + ) + f.write(header) + f.write(struct.pack(f">{n_angles}f", *angles)) + f.write(struct.pack(f">{n_rows}f", *y_positions)) + for p in preambles: + enc = p.encode("utf-8") + f.write(struct.pack(">H", len(enc))) + f.write(enc) + # v4: background waveform block — CH1 64-sample average (Helios ON, Genesis OFF) + f.write(struct.pack(">I", len(background_waveform))) + f.write(background_waveform) + return f + + +# ── T3R worker ─────────────────────────────────────────────────────────────── + +class _T3RReader(QThread): + """Background thread that reads newline-terminated JSON from T3R serial.""" + data_received = pyqtSignal(str) + + def __init__(self, ser: serial.Serial): + super().__init__() + self._ser = ser + self._running = True + + def run(self): + while self._running: + try: + if self._ser.is_open and self._ser.in_waiting: + line = self._ser.readline().decode("utf-8", errors="replace").strip() + if line: + self.data_received.emit(line) + else: + self.msleep(10) + except Exception: + self.msleep(50) + + def stop(self): + self._running = False + self.wait(500) + + +class T3RWorker(QObject): + """Manages T3R stepper controller over JSON serial in a worker thread.""" + connected = pyqtSignal() + disconnected = pyqtSignal() + connection_failed = pyqtSignal(str) + response_received = pyqtSignal(dict) + error_occurred = pyqtSignal(str) + + def __init__(self): + super().__init__() + self._ser: serial.Serial | None = None + self._reader: _T3RReader | None = None + self._cmd_q: queue.Queue = queue.Queue() + self._resp_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.05) + self._dispatch(cmd) + except queue.Empty: + pass + if self._ser and self._ser.is_open: + self._ser.close() + + def _dispatch(self, cmd: dict): + t = cmd["type"] + if t == "connect": + self._do_connect(cmd["port"], cmd["baud"]) + elif t == "disconnect": + self._do_disconnect() + elif t == "send": + self._send_json(cmd["payload"]) + elif t == "stop": + self._running = False + + def _do_connect(self, port: str, baud: int): + try: + self._ser = serial.Serial(port, baud, timeout=0.1) + self._reader = _T3RReader(self._ser) + self._reader.data_received.connect(self._on_line) + self._reader.start() + self.is_connected = True + self.connected.emit() + except Exception as e: + self.connection_failed.emit(str(e)) + + def _do_disconnect(self): + if self._reader: + self._reader.stop() + self._reader = None + if self._ser and self._ser.is_open: + self._ser.close() + self._ser = None + self.is_connected = False + self.disconnected.emit() + + def _send_json(self, payload: dict): + if self._ser and self._ser.is_open: + raw = json.dumps(payload, separators=(",", ":")) + "\n" + self._ser.write(raw.encode()) + + def _on_line(self, line: str): + try: + obj = json.loads(line) + self.response_received.emit(obj) + self._resp_q.put(obj) + except json.JSONDecodeError: + pass + + # ── Queue API (from UI thread) ──────────────────────────────────────────── + + def queue_connect(self, port: str, baud: int = T3R_BAUD): + self._cmd_q.put({"type": "connect", "port": port, "baud": baud}) + + def queue_disconnect(self): + self._cmd_q.put({"type": "disconnect"}) + + def queue_send(self, payload: dict): + self._cmd_q.put({"type": "send", "payload": payload}) + + def queue_enable(self, axis: int, on: bool): + self.queue_send({"verb": "enable", "parameter": str(axis), + "extra": "true" if on else "false"}) + + def queue_set_current(self, axis: int, ma: int): + self.queue_send({"verb": "current", "parameter": str(axis), + "extra": str(ma)}) + + def queue_move(self, axis: int, steps: int, speed: int): + self.queue_send({"verb": "move", "parameter": str(axis), + "extra": f"{steps},{speed}"}) + + # ── Sync API (from scan worker thread) ─────────────────────────────────── + + def send_sync(self, payload: dict, timeout: float = 30.0) -> dict: + """Send a command directly and block until a response arrives. + Must be called from the scan thread, not the UI thread.""" + while not self._resp_q.empty(): + try: + self._resp_q.get_nowait() + except queue.Empty: + break + if self._ser and self._ser.is_open: + raw = json.dumps(payload, separators=(",", ":")) + "\n" + self._ser.write(raw.encode()) + return self._resp_q.get(timeout=timeout) + + def stop_worker(self): + self._cmd_q.put({"type": "stop"}) + + +# ── 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.05) # 100 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) # 50 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"}) + + +# ── Camera window popup ─────────────────────────────────────────────────────── + +class CameraWindow(QWidget): + """Camera display popup — auto-connects on show, auto-disconnects on close.""" + + 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 + + # 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_close_window_btn.clicked.connect(self.close) + + def showEvent(self, event): + super().showEvent(event) + self._start_camera() + + def closeEvent(self, event): + self._stop_camera() + super().closeEvent(event) + + def _start_camera(self): + if self._stream 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) + self._stream = CameraStreamThread(self._camera) + self._stream.frame_ready.connect(self._on_frame) + self._stream.start() + except Exception as e: + QMessageBox.warning(self, "Camera Error", f"Could not open camera:\n{e}") + + def _stop_camera(self): + if self._stream: + self._stream.stop() + self._stream = None + if self._camera: + try: + self._camera.cleanup() + except Exception: + pass + self._camera = None + + 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 + + +# ── Scan progress popup ─────────────────────────────────────────────────────── + +class ScanProgressWindow(QWidget): + abort_requested = pyqtSignal() + + 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) + + def update_progress(self, row: int, n_rows: int, angle_idx: int, n_angles: int): + self.current_scan_current_row_indicator.setText(f"Row {row} of {n_rows}") + pct_row = round(row / n_rows * 100) if n_rows > 0 else 0 + self.current_scan_progbar.setValue(pct_row) + self.overall_scan_current_angle_indicator.setText(f"Angle {angle_idx} of {n_angles}") + pct_ang = round(angle_idx / n_angles * 100) if n_angles > 0 else 0 + self.overall_scan_progbar.setValue(pct_ang) + + +# ── 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() + failed = pyqtSignal(str) + 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 — ask user to acknowledge + + def __init__(self, bbd: BBD202Worker, t3r: T3RWorker, + oscope: OscopeWorker, params: dict): + super().__init__() + self._bbd = bbd + self._t3r = t3r + self._oscope = oscope + self._params = params + self._abort = False + self._prompt_event = threading.Event() + + def abort(self): + self._abort = True + + 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): + p = self._params + x_start = p["x_start"] + y_start = p["y_start"] + x_delta = p["x_delta"] + y_delta = p["y_delta"] + n_angles = max(1, p["num_angles"]) + row_spacing = p["row_spacing"] + prefix = p["prefix"] + save_dir = Path(p["save_dir"]) + + # ── Compute scan geometry ───────────────────────────────────────────── + if n_angles > 1: + angles = [i * 180.0 / (n_angles - 1) for i in range(n_angles)] + else: + angles = [0.0] + + n_rows = max(1, round(y_delta / row_spacing) + 1) if y_delta > 0 else 1 + y_positions = [y_start + i * row_spacing for i in range(n_rows)] + points_per_row = max(1, round(x_delta * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S)) + + # ── Validate scan geometry against stage travel limits ───────────────── + # X axis: 0–110 mm (bbd20x.py). The actual move starts one ramp-length + # + buffer before x_start and ends one ramp-length + buffer after + # x_start + x_delta. + _x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM + x_move_start = x_start - _x_ramp_total + x_move_end = x_start + x_delta + _x_ramp_total + if x_move_start < 0.0: + raise ValueError( + f"Scan pre-ramp start ({x_move_start:.3f} mm) is below the X axis " + f"minimum (0 mm). Increase x_start by at least " + f"{-x_move_start:.3f} mm " + f"(SCAN_RAMP_MM={SCAN_RAMP_MM:.3f} + SCAN_RAMP_BUFFER_MM={SCAN_RAMP_BUFFER_MM:.3f})." + ) + if x_move_end > 110.0: + raise ValueError( + f"Scan run-off end ({x_move_end:.3f} mm) exceeds the X axis " + f"maximum (110 mm). Reduce x_start + x_delta by at least " + f"{x_move_end - 110.0:.3f} mm." + ) + # Y axis: 0–75 mm + y_min = min(y_positions) + y_max = max(y_positions) + if y_min < 0.0: + raise ValueError( + f"Scan Y range starts at {y_min:.3f} mm, below the Y axis minimum (0 mm)." + ) + if y_max > 75.0: + raise ValueError( + f"Scan Y range ends at {y_max:.3f} mm, exceeds the Y axis maximum (75 mm)." + ) + + self.status_msg.emit( + f"Scan geometry: {n_angles} angle(s) × {n_rows} row(s) × " + f"{points_per_row} pts/row | save → {save_dir}" + ) + 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") + + # ── 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() + + # ── 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( + "Resume Scan", + "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 + + # Restore SAMPLE mode and FastFrame for the actual scan. + scope.write("ACQuire:STOPAfter RUNSTop") + scope.set_acquire_mode("SAMPLE") + scope.set_fastframe_state(True) + scope.set_fastframe_count(points_per_row) + + # 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) ─────────────────────── + fname = save_dir / f"{prefix}.sras" + scan_file = _open_scan_file( + fname, n_angles, n_rows, + x_start, x_delta, SCAN_VELOCITY_MM_S, LASER_FREQ_HZ, + points_per_row, samples_per_frame, SCOPE_SAMPLE_RATE, + angles, y_positions, + preambles, background_waveform, + ) + + # ── Scan loop ───────────────────────────────────────────────────────── + self._bbd.scanning_active = True + prev_gr_steps = 0 + try: + for ai, angle in enumerate(angles): + if self._abort: + break + + # ── Rotate GR ───────────────────────────────────────────────── + if self._t3r.is_connected: + target_gr = round(angle * GR_STEPS_PER_DEGREE) + delta_gr = target_gr - prev_gr_steps + if delta_gr != 0: + est_secs = abs(delta_gr) / GR_MOVE_SPEED_HZ + self.status_msg.emit( + f"Rotating GR to {angle:.1f}° (≈{est_secs:.1f} s) …" + ) + try: + self._t3r.send_sync( + {"verb": "move", "parameter": "4", + "extra": f"{delta_gr},{GR_MOVE_SPEED_HZ}"}, + timeout=max(5.0, est_secs + 3.0), + ) + except Exception as e: + self.status_msg.emit(f"GR move warning: {e}") + time.sleep(est_secs + 0.5) + prev_gr_steps = target_gr + + # ── Row loop ────────────────────────────────────────────────── + for ri, y_pos in enumerate(y_positions): + if self._abort: + break + + self.status_msg.emit( + f"Angle {ai+1}/{n_angles} Row {ri+1}/{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, 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 = x_start + 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 = int(scope.query("ACQuire:NUMFRAMESACQuired?")) + for _ in range(n_frames): + scan_file.write(zero_frame) + 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, n_rows, ai + 1, n_angles) + + finally: + scan_file.close() + self._bbd.scanning_active = False + + 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") + + # ── Worker threads ──────────────────────────────────────────────────── + self._t3r_thread = QThread(self) + self._t3r_worker = T3RWorker() + self._t3r_worker.moveToThread(self._t3r_thread) + self._t3r_thread.started.connect(self._t3r_worker.run) + + 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) + + # ── Popup windows ───────────────────────────────────────────────────── + self._camera_win = CameraWindow() + self._scan_progress = ScanProgressWindow() + + self._scan_worker: ScanWorker | None = None + self._scan_thread: QThread | None = None + self._t3r_axes_enabled: set[int] = set() + + # Continuous-jog timers — fire repeatedly while button is held + self._t3r_active_jog: tuple[int, int] | None = None # (axis, direction) + self._t3r_jog_timer = QTimer(self) + self._t3r_jog_timer.setInterval(250) # ms between repeat steps + self._t3r_jog_timer.timeout.connect(self._t3r_jog_tick) + + 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_ui_fields() + self._wire_signals() + + self._t3r_thread.start() + self._bbd_thread.start() + self._oscope_thread.start() + + # ── UI initialisation ───────────────────────────────────────────────────── + + def _init_ui_fields(self): + self.t3r_comport_edit.setText(DEFAULT_T3R_PORT) + self.bbd202_comport_edit.setText(DEFAULT_BBD_PORT) + self.oscope_ip_edit.setText(DEFAULT_OSCOPE_IP) + self.t3r_jog_speed_edit.setText(str(GR_MOVE_SPEED_HZ)) + 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(DEFAULT_SAVE_DIR) + + self._set_t3r_controls_enabled(False) + self._set_bbd_controls_enabled(False) + + # ── Signal wiring ───────────────────────────────────────────────────────── + + def _wire_signals(self): + # T3R + self.t3r_connect_toggle.toggled.connect(self._on_t3r_toggle) + self._t3r_worker.connected.connect(self._on_t3r_connected) + self._t3r_worker.disconnected.connect(self._on_t3r_disconnected) + self._t3r_worker.connection_failed.connect(self._on_t3r_failed) + + self.t3r_enable_t1_btn.toggled.connect(lambda on: self._on_t3r_enable_axis(1, on)) + self.t3r_enable_t2_btn.toggled.connect(lambda on: self._on_t3r_enable_axis(2, on)) + self.t3r_enable_t3_btn.toggled.connect(lambda on: self._on_t3r_enable_axis(3, on)) + self.t3r_enable_gr_btn.toggled.connect(lambda on: self._on_t3r_enable_axis(4, on)) + + self.jog_t1_up_btn.pressed.connect(lambda: self._start_t3r_jog(1, 1)) + self.jog_t1_up_btn.released.connect(self._stop_t3r_jog) + self.jog_t1_down_btn.pressed.connect(lambda: self._start_t3r_jog(1, -1)) + self.jog_t1_down_btn.released.connect(self._stop_t3r_jog) + self.jog_t2_up_btn.pressed.connect(lambda: self._start_t3r_jog(2, 1)) + self.jog_t2_up_btn.released.connect(self._stop_t3r_jog) + self.jog_t2_down_btn.pressed.connect(lambda: self._start_t3r_jog(2, -1)) + self.jog_t2_down_btn.released.connect(self._stop_t3r_jog) + self.jog_t3_up_btn.pressed.connect(lambda: self._start_t3r_jog(3, 1)) + self.jog_t3_up_btn.released.connect(self._stop_t3r_jog) + self.jog_t3_down_btn.pressed.connect(lambda: self._start_t3r_jog(3, -1)) + self.jog_t3_down_btn.released.connect(self._stop_t3r_jog) + self.jog_gr_ccw_btn.pressed.connect(lambda: self._start_t3r_jog(4, -1)) + self.jog_gr_ccw_btn.released.connect(self._stop_t3r_jog) + self.jog_gr_cw_btn.pressed.connect(lambda: self._start_t3r_jog(4, 1)) + self.jog_gr_cw_btn.released.connect(self._stop_t3r_jog) + self.t3r_set_current_t1_btn.clicked.connect(lambda: self._set_t3r_current(1, self.t3r_current_t1_spin.value())) + self.t3r_set_current_t2_btn.clicked.connect(lambda: self._set_t3r_current(2, self.t3r_current_t2_spin.value())) + self.t3r_set_current_t3_btn.clicked.connect(lambda: self._set_t3r_current(3, self.t3r_current_t3_spin.value())) + self.t3r_set_current_gr_btn.clicked.connect(lambda: self._set_t3r_current(4, self.t3r_current_gr_spin.value())) + + # 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.uc480_close_window_btn.clicked.connect( + lambda: self.show_camera_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) + + # ── T3R slots ───────────────────────────────────────────────────────────── + + def _on_t3r_toggle(self, checked: bool): + if checked: + self.t3r_connect_toggle.setText("Connecting…") + self.t3r_connect_toggle.setEnabled(False) + self._t3r_worker.queue_connect(self.t3r_comport_edit.text().strip()) + else: + self._t3r_worker.queue_disconnect() + + def _on_t3r_connected(self): + self.t3r_connect_toggle.setEnabled(True) + self.t3r_connect_toggle.setText("Disconnect") + self._set_t3r_controls_enabled(True) + + def _on_t3r_disconnected(self): + self._set_toggle(self.t3r_connect_toggle, False, "Connect") + self._set_t3r_controls_enabled(False) + self._t3r_axes_enabled = set() + for axis in (1, 2, 3, 4): + btn = self._t3r_axis_enable_btn(axis) + btn.blockSignals(True) + btn.setChecked(False) + btn.setText(f"Enable {_T3R_AXIS_NAMES[axis]}") + btn.blockSignals(False) + + def _on_t3r_failed(self, msg: str): + self._set_toggle(self.t3r_connect_toggle, False, "Connect") + self.t3r_connect_toggle.setEnabled(True) + QMessageBox.warning(self, "T3R Connection Failed", msg) + + def _t3r_axis_enable_btn(self, axis: int): + return {1: self.t3r_enable_t1_btn, 2: self.t3r_enable_t2_btn, + 3: self.t3r_enable_t3_btn, 4: self.t3r_enable_gr_btn}[axis] + + def _on_t3r_enable_axis(self, axis: int, enabled: bool): + if enabled: + self._t3r_axes_enabled.add(axis) + else: + self._t3r_axes_enabled.discard(axis) + self._t3r_worker.queue_enable(axis, enabled) + name = _T3R_AXIS_NAMES[axis] + self._t3r_axis_enable_btn(axis).setText( + f"{'Disable' if enabled else 'Enable'} {name}" + ) + + def _start_t3r_jog(self, axis: int, direction: int): + if axis not in self._t3r_axes_enabled: + return + self._t3r_active_jog = (axis, direction) + self._t3r_jog_tick() + self._t3r_jog_timer.start() + + def _t3r_jog_tick(self): + if self._t3r_active_jog is None: + return + axis, direction = self._t3r_active_jog + try: + speed = int(self.t3r_jog_speed_edit.text()) + except ValueError: + speed = T1T2T3_JOG_STEPS + steps = (GR_JOG_STEPS if axis == 4 else T1T2T3_JOG_STEPS) * direction + self._t3r_worker.queue_move(axis, steps, speed) + + def _stop_t3r_jog(self): + self._t3r_jog_timer.stop() + self._t3r_active_jog = None + + def _set_t3r_current(self, axis: int, ma: int): + self._t3r_worker.queue_set_current(axis, ma) + + def _set_t3r_controls_enabled(self, on: bool): + 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, + ): + w.setEnabled(on) + + # ── 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): + _save_aui_defaults({ + "t3r_port": self.t3r_comport_edit.text().strip(), + "bbd_port": self.bbd202_comport_edit.text().strip(), + "oscope_ip": self.oscope_ip_edit.text().strip(), + "laser_freq_hz": DEFAULT_LASER_FREQ_HZ, + "save_dir": self.scan_save_dir_edit.text().strip(), + }) + + # ── 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() + + # ── 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: + params = self._build_scan_params() + except ValueError as e: + QMessageBox.warning(self, "Invalid Scan Parameters", str(e)) + return + + # ── Launch scan worker ──────────────────────────────────────────────── + self._scan_thread = QThread(self) + self._scan_worker = ScanWorker( + self._bbd_worker, self._t3r_worker, self._oscope_worker, params + ) + self._scan_worker.moveToThread(self._scan_thread) + self._scan_thread.started.connect(self._scan_worker.run) + self._scan_worker.row_done.connect(self._on_row_done) + 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.start_scan_btn.setEnabled(False) + self._scan_progress.update_progress(0, params["n_rows"], 0, params["num_angles"]) + self._scan_progress.show() + self._scan_thread.start() + + def _build_scan_params(self) -> dict: + def _f(w, label): + try: + return float(w.text()) + except ValueError: + raise ValueError(f"'{label}' is not a valid number: {w.text()!r}") + def _i(w, label): + try: + return int(w.text()) + except ValueError: + raise ValueError(f"'{label}' is not a valid integer: {w.text()!r}") + + x_start = _f(self.x_start_edit, "XS") + y_start = _f(self.y_start_edit, "YS") + x_delta = _f(self.x_delta_edit, "XD") + y_delta = _f(self.y_delta_edit, "YD") + num_angles = _i(self.num_angles_edit, "NumAngles") + row_spacing = _f(self.row_spacing_edit,"RowSpacing") + prefix = self.scan_prefix_edit.text().strip() or "scan" + save_dir = self.scan_save_dir_edit.text().strip() or DEFAULT_SAVE_DIR + + if x_delta <= 0: + raise ValueError("XD must be > 0") + if row_spacing <= 0: + raise ValueError("RowSpacing must be > 0") + if num_angles < 1: + raise ValueError("NumAngles must be ≥ 1") + + n_rows = max(1, round(y_delta / row_spacing) + 1) if y_delta > 0 else 1 + + return { + "x_start": x_start, "y_start": y_start, + "x_delta": x_delta, "y_delta": y_delta, + "num_angles": num_angles, + "row_spacing": row_spacing, + "laser_freq": DEFAULT_LASER_FREQ_HZ, + "prefix": prefix, + "save_dir": save_dir, + "n_rows": n_rows, + } + + 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 _disconnect_all_and_close(self): + """Cleanly disconnect all hardware then quit.""" + if self._t3r_worker.is_connected: + self._t3r_worker.queue_disconnect() + if self._bbd_worker.is_connected: + self._bbd_worker.queue_disconnect() + if self._oscope_worker.is_connected: + self._oscope_worker.queue_disconnect() + self._camera_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() + for w in (self._t3r_worker, self._bbd_worker, self._oscope_worker): + w.stop_worker() + for t in (self._t3r_thread, self._bbd_thread, self._oscope_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() diff --git a/scan_format.md b/scan_format.md new file mode 100644 index 0000000..abfceec --- /dev/null +++ b/scan_format.md @@ -0,0 +1,213 @@ +# SRAS Scan Binary Format — Version 4 + +Each `.sras` file contains **one complete scan**: all GR rotation angles and all +Y rows. Files are named `{prefix}.sras`. + +--- + +## File Layout + +``` +[Global Header — 43 bytes] +[Angle Table — n_angles × 4 bytes (float32 per angle)] +[Row Table — n_rows × 4 bytes (float32 per row)] +[Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)] +[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes] +[Waveform Data — n_angles × n_rows × n_channels × n_frames × samples_per_frame × bps bytes] +``` + +All multi-byte integers and floats use **big-endian** byte order +(`>` in Python's `struct` module). + +--- + +## Global Header (42 bytes) + +| Offset | Size | Type | Field | Description | +|--------|------|-----------|--------------------|--------------------------------------------------| +| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) | +| 4 | 1 | `uint8` | `version` | Format version — `4` | +| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles | +| 7 | 2 | `uint16` | `n_rows` | Number of Y rows per angle | +| 9 | 4 | `float32` | `x_start_mm` | X scan start position in mm | +| 13 | 4 | `float32` | `x_delta_mm` | X scan width in mm | +| 17 | 4 | `float32` | `velocity_mm_s` | Stage scan velocity in mm/s | +| 21 | 4 | `float32` | `laser_freq_hz` | Laser repetition rate in Hz | +| 25 | 4 | `uint32` | `n_frames` | A-scans per row (= FastFrame count per channel) | +| 29 | 4 | `uint32` | `samples_per_frame`| Time samples per waveform | +| 33 | 8 | `float64` | `sample_rate_hz` | Oscilloscope sample rate in Hz (e.g. 6.25e9) | +| 41 | 1 | `uint8` | `bytes_per_sample` | Bytes per ADC sample: `1` = int8, `2` = int16 | +| 42 | 1 | `uint8` | `n_channels` | Number of channels recorded (currently `3`) | + +**Total header size:** 43 bytes — verified: +`struct.calcsize(">4sBHHffffIIdBB") == 43`. + +--- + +## Angle Table + +Immediately after the header: **n_angles** big-endian float32 values, one per +GR angle (degrees, 0–180). + +``` +angle[0], angle[1], …, angle[n_angles - 1] +``` + +--- + +## Row Table + +Immediately after the angle table: **n_rows** big-endian float32 values, one +per Y row (mm). + +``` +y_mm[0], y_mm[1], …, y_mm[n_rows - 1] +``` + +--- + +## Preamble Blocks + +Immediately after the row table: **n_channels** length-prefixed UTF-8 strings, +one per channel in `SCAN_CHANNELS` order (CH1, CH3, CH4). Each block is: + +``` +uint16 length — byte length of the following UTF-8 string +bytes preamble — WFMOutpre response string from the oscilloscope +``` + +The preamble captures per-channel scaling constants (YMULT, YOFF, YZERO) needed +to convert raw ADC values to volts. + +--- + +## Background Block + +Immediately after the preamble blocks: a single CH1 waveform captured with the +**Helios (generation) laser enabled** and the **Genesis (detection) laser +disabled**. This provides a noise/background reference for subtraction during +post-processing. + +``` +uint32 n_bg_samples — number of samples in the background waveform +int8[] bg_data — raw ADC samples (same encoding as waveform data) +``` + +`n_bg_samples` equals `samples_per_frame` under normal acquisition settings. + +--- + +## Waveform Data + +Immediately after the background block. Data is stored in **angle-major, row-minor** +order. Within each row, channels are interleaved in ascending channel-index +order, with each channel's FastFrame data written in frame order. + +``` +for angle in 0 … n_angles-1: + for row in 0 … n_rows-1: + for channel in [CH1, CH3, CH4]: # 3 channels, fixed order + for frame in 0 … n_frames-1: + samples[0 … samples_per_frame-1] # bps bytes each +``` + +Each sample is a raw signed ADC value. With `bytes_per_sample = 1` this is +**int8** (−128 … +127). With `bytes_per_sample = 2` this is **big-endian +int16**. + +Total data size: +``` +n_angles × n_rows × 3 × n_frames × samples_per_frame × bytes_per_sample +``` + +> **Incomplete files:** If a scan is aborted the file is closed immediately and +> the data block will be shorter than the expected size. Readers should check +> `file_size >= header + angle_table + row_table + data` before reshaping. + +--- + +## Spatial Mapping + +The *k*-th waveform (frame) in a row corresponds to the *k*-th laser pulse that +hit the sample. The physical X position of that pulse is: + +``` +x_k = x_start_mm + k * (velocity_mm_s / laser_freq_hz) +``` + +--- + +## Python Read Example + +```python +import struct, numpy as np +from pathlib import Path + +HDR_FMT = ">4sBHHffffIIdBB" +HDR_SIZE = struct.calcsize(HDR_FMT) # 43 bytes + +def read_sras(path): + with open(path, "rb") as f: + hdr = struct.unpack(HDR_FMT, f.read(HDR_SIZE)) + magic, ver, n_angles, n_rows, xs, xd, vel, freq, nf, spf, sr, bps, n_ch = hdr + assert magic == b"SRAS" and ver == 4, "Not a v4 SRAS file" + + angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4") + y_positions = np.frombuffer(f.read(n_rows * 4), dtype=">f4") + + # Preamble blocks (one per channel) + preambles = [] + for _ in range(n_ch): + (plen,) = struct.unpack(">H", f.read(2)) + preambles.append(f.read(plen).decode("utf-8")) + + # Background waveform block (v4+) + (n_bg,) = struct.unpack(">I", f.read(4)) + background = np.frombuffer(f.read(n_bg), dtype=np.int8) + + dtype = np.int8 if bps == 1 else ">i2" + data = np.frombuffer(f.read(), dtype=dtype).reshape( + n_angles, n_rows, n_ch, nf, spf + ) + + return { + "angles_deg": angles, + "y_positions_mm": y_positions, + "x_start_mm": xs, + "x_delta_mm": xd, + "velocity_mm_s": vel, + "laser_freq_hz": freq, + "sample_rate_hz": sr, + "n_channels": n_ch, # 3: CH1, CH3, CH4 (see Acquisition Settings) + "preambles": preambles, # WFMOutpre strings, same order as n_channels + "background": background,# shape: (n_bg_samples,) — CH1 noise reference + # shape: (n_angles, n_rows, n_channels, n_frames, samples_per_frame) + "data": data, + } +``` + +--- + +## Acquisition Settings (fixed by sc3_aui_app.py) + +| Parameter | Value | +|----------------------|------------------------------| +| Oscilloscope trigger | CH2, rising edge, 1.24 V | +| Trigger offset | 0 % (trigger at left edge) | +| Sample rate | 6.25 GS/s (160 ps/sample) | +| Channels recorded | CH1, CH3, CH4 | +| Stage X velocity | 100 mm/s | +| Stage X acceleration | 1500 mm/s² | +| Stage X trigger out | Logic-high at max velocity | +| Acquisition mode | FastFrame, Normal trigger | + +--- + +## Version History + +| Version | Change | +|---------|--------| +| 1 | One file per row; header included `angle_idx`, `row_idx`, `angle_deg`, `y_mm`. | +| 2 | One file per scan; global header with `n_angles`/`n_rows`; separate angle and row tables; three channels (CH1, CH3, CH4) per row. | +| 3 | Added preamble blocks (WFMOutpre strings) after the row table, one length-prefixed UTF-8 block per channel. | +| 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. | diff --git a/sras_viewer.py b/sras_viewer.py new file mode 100644 index 0000000..7c6954f --- /dev/null +++ b/sras_viewer.py @@ -0,0 +1,1961 @@ +#!/usr/bin/env python3 +""" +SRAS Scan File Viewer +PyQt6 application for visualizing channel data from .sras binary scan files. + +Channel semantics (fixed by sc3_aui_app.py acquisition settings): + CH1 — RF Acoustic Packet (AC-coupled, 100 mV/div): FFT → peak frequency + CH3 — Bias A (DC-coupled, 50 mV/div): waveform mean + CH4 — Bias B (DC-coupled, 50 mV/div): waveform mean + +RF images are masked: pixels where CH4_dc < dc_threshold show 0. + +Frame-count correction: the scanner writes the *configured* frame count in the +header before acquisition, but the scope may acquire fewer frames. The actual +count is computed from the file size and used for the reshape so channels are +correctly aligned. +""" + +import re +import sys +import struct +import numpy as np +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor +import os +from scipy.signal import butter, sosfiltfilt, decimate as sp_decimate, hilbert + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QGroupBox, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox, + QFileDialog, QSizePolicy, QSplitter, QCheckBox, QFrame, QProgressDialog, +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT +from matplotlib.figure import Figure + +# --------------------------------------------------------------------------- +# SAW signal processing pipeline +# --------------------------------------------------------------------------- + +class SawPipeline: + """Modular EMI-cleaning and SAW extraction pipeline. + + Stages (each independently bypassable): + 1. EMI gate — cosine-taper first `emi_gate_ns` ns to suppress the + laser-firing burst at t≈0; leaves SAW packet untouched. + 2. Bandpass — 6th-order Butterworth zero-phase (sosfiltfilt), default + 85–200 MHz. Matches hardware bandpass already applied. + 3. Decimate — optional; reduces to ~781 MS/s (factor-8) before the + matched filter without losing SAW information. + 4. Matched filter — FFT cross-correlation with a Hann-windowed template + built from the average of N clean shots. + 5. Analytic — Hilbert transform of MF output → amplitude envelope and + instantaneous phase. + + Typical usage:: + + pipe = SawPipeline(sras.sample_rate_hz) + waveforms = sras.data[angle, :, CH1_IDX, :, :].reshape(-1, spf) + pipe.build_template(waveforms[:50]) + result = pipe.process_shot(waveform) + # result["envelope"], result["peak_amplitude"], result["peak_time_ns"] + """ + + DECIMATE_FACTOR = 8 # 6250 MS/s → 781.25 MS/s (~4× SAW BW of 200 MHz) + + def __init__(self, sample_rate_hz: float, + emi_gate_ns: float = 50.0, + bp_lo_mhz: float = 85.0, + bp_hi_mhz: float = 200.0, + saw_window_ns: tuple[float, float] = (80.0, 350.0), + decimate_enable: bool = False): + self.sample_rate_hz = float(sample_rate_hz) + self.emi_gate_ns = float(emi_gate_ns) + self.bp_lo_mhz = float(bp_lo_mhz) + self.bp_hi_mhz = float(bp_hi_mhz) + self.saw_window_ns = (float(saw_window_ns[0]), float(saw_window_ns[1])) + self.decimate_enable = decimate_enable + self.template: np.ndarray | None = None + self._emi_gate_samples: int = 0 + self._sos = None + self._effective_sr = self.sample_rate_hz + self._build_filter() + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + def _build_filter(self): + self._emi_gate_samples = max(1, int(round( + self.emi_gate_ns * 1e-9 * self.sample_rate_hz))) + nyq = self.sample_rate_hz / 2.0 + lo = np.clip(self.bp_lo_mhz * 1e6 / nyq, 1e-6, 0.999) + hi = np.clip(self.bp_hi_mhz * 1e6 / nyq, lo + 1e-6, 0.9999) + # 6th-order Butterworth → 12th-order bandpass; ~120 dB/decade rolloff + self._sos = butter(6, [lo, hi], btype='bandpass', output='sos') + self._effective_sr = (self.sample_rate_hz / self.DECIMATE_FACTOR + if self.decimate_enable else self.sample_rate_hz) + + # ------------------------------------------------------------------ + # Individual stages + # ------------------------------------------------------------------ + + def gate_emi(self, signal: np.ndarray) -> np.ndarray: + """Cosine-taper (raised cosine 0→1) the first `emi_gate_samples` samples. + + The taper rolls up smoothly from zero so the abrupt EMI burst is + suppressed without introducing a step discontinuity at the gate edge. + """ + n = min(self._emi_gate_samples, len(signal)) + out = signal.copy() + out[:n] *= 0.5 * (1.0 - np.cos(np.pi * np.arange(n) / n)) + return out + + def bandpass(self, signal: np.ndarray) -> np.ndarray: + """Zero-phase IIR Butterworth bandpass (sosfiltfilt).""" + return sosfiltfilt(self._sos, signal.astype(np.float64)).astype(np.float32) + + def decimate_signal(self, signal: np.ndarray) -> np.ndarray: + """Decimate by DECIMATE_FACTOR with scipy anti-alias filter.""" + return sp_decimate(signal.astype(np.float64), self.DECIMATE_FACTOR, + zero_phase=True).astype(np.float32) + + # ------------------------------------------------------------------ + # Template construction + # ------------------------------------------------------------------ + + def build_template(self, waveforms: np.ndarray) -> None: + """Build Hann-windowed average template. + + Parameters + ---------- + waveforms : ndarray, shape (N, n_samples) + Raw or pre-processed CH1 waveforms. EMI gating + bandpass are + applied here before averaging so the template is clean. + """ + processed = np.stack([ + self.bandpass(self.gate_emi(w.astype(np.float32))) + for w in waveforms + ]) + avg = processed.mean(axis=0) + + # Hann window restricted to the declared SAW window region + n = len(avg) + t_ns = np.arange(n) / self.sample_rate_hz * 1e9 + i0 = max(0, int(np.searchsorted(t_ns, self.saw_window_ns[0]))) + i1 = min(n, int(np.searchsorted(t_ns, self.saw_window_ns[1]))) + windowed = np.zeros(n, dtype=np.float32) + win_len = i1 - i0 + if win_len > 0: + windowed[i0:i1] = avg[i0:i1] * np.hanning(win_len) + self.template = windowed + + # ------------------------------------------------------------------ + # Matched filter + # ------------------------------------------------------------------ + + def matched_filter(self, signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """FFT cross-correlation with template. + + Returns + ------- + mf_output : float32 ndarray, length = len(signal) + envelope : float32 ndarray, Hilbert amplitude envelope of mf_output + """ + if self.template is None: + raise RuntimeError("No template — call build_template() first") + n = len(signal) + nfft = 1 << (n + len(self.template) - 1).bit_length() + S = np.fft.rfft(signal.astype(np.float64), nfft) + T = np.fft.rfft(self.template.astype(np.float64), nfft) + mf = np.fft.irfft(S * np.conj(T), nfft)[:n] + env = np.abs(hilbert(mf)) + return mf.astype(np.float32), env.astype(np.float32) + + # ------------------------------------------------------------------ + # Full pipeline for a single waveform + # ------------------------------------------------------------------ + + def process_shot(self, signal: np.ndarray) -> dict: + """Run EMI gate → bandpass → (decimate) → matched filter on one shot. + + Returns a dict with keys: + raw, gated, filtered, [decimated], mf_output, envelope, + peak_amplitude (float), peak_sample (int), peak_time_ns (float), + snr (float), sample_rate_hz (float). + """ + raw = signal.astype(np.float32) + gated = self.gate_emi(raw) + filtered = self.bandpass(gated) + + if self.decimate_enable: + proc = self.decimate_signal(filtered) + sr = self._effective_sr + else: + proc = filtered + sr = self.sample_rate_hz + + if self.template is not None: + mf_out, env = self.matched_filter(proc) + else: + mf_out = proc.copy() + env = np.abs(hilbert(proc)).astype(np.float32) + + t_ns = np.arange(len(env)) / sr * 1e9 + + # Peak within SAW window + s0, s1 = self.saw_window_ns + roi = (t_ns >= s0) & (t_ns <= s1) + if roi.any(): + idx_in_roi = np.argmax(env[roi]) + peak_sample = int(np.where(roi)[0][idx_in_roi]) + else: + peak_sample = int(np.argmax(env)) + peak_amplitude = float(env[peak_sample]) + peak_time_ns = float(peak_sample / sr * 1e9) + + # SNR: peak / RMS of noise floor in the gated EMI region (after bandpass) + noise_seg = filtered[:self._emi_gate_samples] + noise_rms = float(np.sqrt(np.mean(noise_seg ** 2))) if len(noise_seg) > 0 else 1.0 + snr = peak_amplitude / noise_rms if noise_rms > 0 else 0.0 + + return { + "raw": raw, + "gated": gated, + "filtered": filtered, + "mf_output": mf_out, + "envelope": env, + "peak_amplitude": peak_amplitude, + "peak_sample": peak_sample, + "peak_time_ns": peak_time_ns, + "snr": snr, + "sample_rate_hz": sr, + } + + +# --------------------------------------------------------------------------- +# SRAS format +# --------------------------------------------------------------------------- + +HDR_FMT = ">4sBHHffffIIdBB" +HDR_SIZE = struct.calcsize(HDR_FMT) # 43 bytes + +# Fixed-order channels in the file: index 0=CH1, 1=CH3, 2=CH4 +# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC) +CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2 + +CH_LABELS = [ + "CH1 — RF (FFT peak freq)", + "CH3 — Bias A (DC mean)", + "CH4 — Bias B (DC mean)", + "CH1 — Velocity (SRAS)", + "CH1 — SAW Amplitude (matched filter)", + "CH1 — SAW Arrival time (matched filter)", +] +CH_NAMES = ["CH1", "CH3", "CH4", "VEL", "SAW-AMP", "SAW-TOF"] + +# Combo indices for derived modes (all use CH1_IDX data) +VELOCITY_MODE_IDX = 3 +SAW_MODE_AMP_IDX = 4 +SAW_MODE_TOF_IDX = 5 +SAW_MODES = (SAW_MODE_AMP_IDX, SAW_MODE_TOF_IDX) +# All modes that operate on CH1 waveforms +CH1_DERIVED_MODES = (CH1_IDX, VELOCITY_MODE_IDX) + SAW_MODES + +# Fallback scope calibration used only when reading v2 files without embedded +# preambles. v3+ files carry the WFMOutpre string so these are not used. +# 50 mV/div, 8 div full-scale, int8 ADC, position = -2.72 div +# ymult = 50 mV × 8 / 256 = 1.5625 mV/count +# yoff = position × (256/8) = -2.72 × 32 = -87.04 (ADC count for 0 V) +_FALLBACK_YMULT_MV = 1.5625 # mV per ADC count +_FALLBACK_YOFF_ADC = -87.04 # ADC count that represents 0 V + +CMAPS = ["gray", "viridis", "plasma", "inferno", "hot", "jet", "RdBu_r", "seismic"] + + +def _parse_preamble(preamble: str) -> dict[str, float]: + """Extract YMULT, YOFF, YZERO from a Tektronix WFMOutpre string. + + Returns a dict with float values for whichever keys are present. + YMULT is left in V/count as the scope reports it. + """ + result = {} + for key in ("YMULT", "YOFF", "YZERO"): + m = re.search(rf'\b{key}\s+([-+]?\d*\.?\d+(?:[Ee][+-]?\d+)?)', preamble) + if m: + result[key] = float(m.group(1)) + return result + + +def mv_to_adc(mv: float, ymult_mv: float = _FALLBACK_YMULT_MV, + yoff_adc: float = _FALLBACK_YOFF_ADC, + yzero_mv: float = 0.0) -> float: + return (mv - yzero_mv) / ymult_mv + yoff_adc + + +def adc_to_mv(adc: float, ymult_mv: float = _FALLBACK_YMULT_MV, + yoff_adc: float = _FALLBACK_YOFF_ADC, + yzero_mv: float = 0.0) -> float: + return (adc - yoff_adc) * ymult_mv + yzero_mv + + +# --------------------------------------------------------------------------- +# File parser +# --------------------------------------------------------------------------- + +class SrasFile: + """Parsed in-memory representation of a v2/v3/v4 .sras file.""" + + def __init__(self, path: str): + self.path = Path(path) + self._parse() + + def _parse(self): + with open(self.path, "rb") as f: + fields = struct.unpack(HDR_FMT, f.read(HDR_SIZE)) + (magic, ver, n_angles, n_rows, x_start, x_delta, vel, freq, + n_frames_hdr, spf, sr, bps, n_ch) = fields + + if magic != b"SRAS": + raise ValueError(f"Bad magic bytes: {magic!r}") + if ver not in (2, 3, 4): + raise ValueError(f"Unsupported version: {ver}") + + self.n_angles = n_angles + self.n_rows = n_rows + self.x_start_mm = float(x_start) + self.x_delta_mm = float(x_delta) + self.velocity_mm_s = float(vel) + self.laser_freq_hz = float(freq) + self.n_frames_header = n_frames_hdr # configured count (may be wrong) + self.samples_per_frame = spf + self.sample_rate_hz = float(sr) + self.bytes_per_sample = bps + self.n_channels = n_ch + + with open(self.path, "rb") as f: + f.seek(HDR_SIZE) + angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32) + y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32) + + if ver >= 3: + preambles = [] + for _ in range(n_ch): + (length,) = struct.unpack(">H", f.read(2)) + preambles.append(f.read(length).decode("utf-8")) + self.preambles = preambles + self.ch_ymult_mv = [] + self.ch_yoff_adc = [] + self.ch_yzero_mv = [] + for p in preambles: + cal = _parse_preamble(p) + # YMULT from scope is V/count; store as mV/count + self.ch_ymult_mv.append(cal.get("YMULT", _FALLBACK_YMULT_MV / 1000) * 1000) + self.ch_yoff_adc.append(cal.get("YOFF", _FALLBACK_YOFF_ADC)) + # YZERO from scope is in V; store as mV + self.ch_yzero_mv.append(cal.get("YZERO", 0.0) * 1000) + else: + self.preambles = None + self.ch_ymult_mv = [_FALLBACK_YMULT_MV] * n_ch + self.ch_yoff_adc = [_FALLBACK_YOFF_ADC] * n_ch + self.ch_yzero_mv = [0.0] * n_ch + + if ver >= 4: + (n_bg,) = struct.unpack(">I", f.read(4)) + self.background = np.frombuffer(f.read(n_bg), dtype=np.int8).astype(np.float32) + else: + self.background = None + + raw = f.read() + + total_samples = len(raw) // bps + samples_per_row_per_ch = n_ch * spf + + # Compute actual frames per channel from the file size — the scanner + # writes the configured frame count in the header before acquisition + # begins, but ACQuire:NUMFRAMESACQuired may be lower. + actual_n_frames = total_samples // (n_angles * n_rows * samples_per_row_per_ch) + remainder = total_samples % (n_angles * n_rows * samples_per_row_per_ch) + + self.n_frames = actual_n_frames # actual, use this for all indexing + self.n_frames_header = n_frames_hdr + self.frame_count_mismatch = (actual_n_frames != n_frames_hdr) + self.n_frames_remainder = remainder # partial last-row samples + + # Reshape using the actual count; discard any fractional last row + dtype = np.int8 if bps == 1 else ">i2" + good = n_angles * n_rows * n_ch * actual_n_frames * spf + data = np.frombuffer(raw[:good * bps], dtype=dtype) + data = data.reshape(n_angles, n_rows, n_ch, actual_n_frames, spf) + self.data = data.astype(np.int16 if bps == 2 else np.int8) + + self.angles_deg = angles + self.y_positions_mm = y_pos + + # ------------------------------------------------------------------ + # Axes helpers + # ------------------------------------------------------------------ + + @property + def pixel_x_mm(self) -> float: + return self.velocity_mm_s / self.laser_freq_hz + + def x_axis_mm(self) -> np.ndarray: + return self.x_start_mm + np.arange(self.n_frames) * self.pixel_x_mm + + def time_axis_ns(self) -> np.ndarray: + return np.arange(self.samples_per_frame) / self.sample_rate_hz * 1e9 + + def freq_axis_mhz(self) -> np.ndarray: + return np.fft.rfftfreq(self.samples_per_frame, d=1.0 / self.sample_rate_hz) / 1e6 + + +# --------------------------------------------------------------------------- +# Image computation (vectorised) +# --------------------------------------------------------------------------- + + + +def compute_dc_image(sras: SrasFile, angle_idx: int, ch_idx: int) -> np.ndarray: + """Mean of each waveform → (n_rows, n_frames) float32.""" + return sras.data[angle_idx, :, ch_idx, :, :].astype(np.float32).mean(axis=-1) + + +def compute_rf_image(sras: SrasFile, angle_idx: int, + dc_threshold_mv: float, + apply_bg_sub: bool = True, + gate_start_ns: float | None = None, + gate_end_ns: float | None = None) -> np.ndarray: + """ + FFT of each CH1 waveform; pixel = peak frequency in MHz. + Pixels where CH4_dc < dc_threshold_mv are set to 0; FFT is skipped for + those pixels entirely. DC is always computed before any FFT work. + The threshold and DC mean are both in mV, using per-channel calibration + from the file (or fallback constants for v2 files). + + If apply_bg_sub is True and the file contains a background waveform + (v4+), each CH1 waveform has the background subtracted before the FFT. + + gate_start_ns / gate_end_ns: when either is set, samples outside the + [start, end] time window are zeroed before the FFT (time-domain gating). + """ + # --- Step 1: compute CH4 DC mask before any FFT work --- + dc4_mv = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX), + sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX], + sras.ch_yzero_mv[CH4_IDX]) + mask = dc4_mv < dc_threshold_mv # True = below threshold = skip FFT + valid = ~mask # pixels that require FFT + + img = np.zeros(mask.shape, dtype=np.float32) + + if valid.any(): + # --- Step 2: FFT only on pixels that passed the DC threshold --- + waveforms = sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32) + # shape: (n_rows, n_frames, samples_per_frame) + + if apply_bg_sub and sras.background is not None: + waveforms = waveforms - sras.background[np.newaxis, np.newaxis, :] + + if gate_start_ns is not None or gate_end_ns is not None: + t_ns = sras.time_axis_ns() + keep = np.ones(len(t_ns), dtype=bool) + if gate_start_ns is not None: + keep &= t_ns >= gate_start_ns + if gate_end_ns is not None: + keep &= t_ns <= gate_end_ns + waveforms = waveforms.copy() + waveforms[..., ~keep] = 0.0 + + valid_waves = waveforms[valid] # (n_valid, spf) + fft_pow = np.abs(np.fft.rfft(valid_waves, axis=-1)) ** 2 + fft_pow[:, 0] = 0.0 # suppress DC bin + peak_bins = np.argmax(fft_pow, axis=-1) # (n_valid,) + img[valid] = sras.freq_axis_mhz()[peak_bins] + + return img + + +def compute_saw_image(sras: SrasFile, angle_idx: int, dc_threshold_mv: float, + pipeline: SawPipeline, mode: str, + apply_bg_sub: bool = True) -> np.ndarray: + """Run the SAW matched-filter pipeline over every pixel. + + mode : "amplitude" → MF envelope peak in SAW window + "tof" → arrival time (ns) of that peak + Returns (n_rows, n_frames) float32, DC-masked. + """ + # --- Step 1: compute CH4 DC mask before running the pipeline --- + dc4_mv = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX), + sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX], + sras.ch_yzero_mv[CH4_IDX]) + mask = dc4_mv < dc_threshold_mv # True = below threshold = skip pipeline + valid = ~mask + + img = np.zeros(mask.shape, dtype=np.float32) + + if valid.any(): + # --- Step 2: run pipeline only on pixels that passed the DC threshold --- + waveforms = sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32) + if apply_bg_sub and sras.background is not None: + waveforms = waveforms - sras.background[np.newaxis, np.newaxis, :] + + valid_waves = waveforms[valid] # (n_valid, spf) + n_workers = min(os.cpu_count() or 4, len(valid_waves)) + with ThreadPoolExecutor(max_workers=n_workers) as executor: + results = list(executor.map(pipeline.process_shot, valid_waves)) + + if mode == "amplitude": + vals = np.array([r["peak_amplitude"] for r in results], dtype=np.float32) + else: + vals = np.array([r["peak_time_ns"] for r in results], dtype=np.float32) + + img[valid] = vals + + return img + + +# --------------------------------------------------------------------------- +# Background workers +# --------------------------------------------------------------------------- + +class LoadWorker(QObject): + finished = pyqtSignal(object) # SrasFile | None + error = pyqtSignal(str) + + def __init__(self, path: str): + super().__init__() + self._path = path + + def run(self): + try: + self.finished.emit(SrasFile(self._path)) + except Exception as exc: + self.error.emit(str(exc)) + self.finished.emit(None) + + +class ComputeWorker(QObject): + finished = pyqtSignal(np.ndarray) + error = pyqtSignal(str) + + def __init__(self, sras: SrasFile, angle_idx: int, + ch_idx: int, dc_threshold_mv: float, + grating_um: float = 12.5, + apply_bg_sub: bool = True, + gate_start_ns: float | None = None, + gate_end_ns: float | None = None, + saw_pipeline: "SawPipeline | None" = None): + super().__init__() + self._sras = sras + self._angle = angle_idx + self._ch = ch_idx + self._threshold = dc_threshold_mv + self._grating_um = grating_um + self._apply_bg_sub = apply_bg_sub + self._gate_start = gate_start_ns + self._gate_end = gate_end_ns + self._saw_pipeline = saw_pipeline + + def run(self): + try: + if self._ch == CH1_IDX: + img = compute_rf_image(self._sras, self._angle, self._threshold, + self._apply_bg_sub, + self._gate_start, self._gate_end) + elif self._ch == VELOCITY_MODE_IDX: + # velocity (m/s) = freq (MHz) × grating (µm) [units cancel to m/s] + img = compute_rf_image(self._sras, self._angle, self._threshold, + self._apply_bg_sub, + self._gate_start, self._gate_end) + img = img * self._grating_um + elif self._ch in SAW_MODES: + if self._saw_pipeline is None or self._saw_pipeline.template is None: + raise RuntimeError( + "SAW pipeline: no template built yet.\n" + "Use \"Build Template\" in the SAW Pipeline panel first.") + mode = "amplitude" if self._ch == SAW_MODE_AMP_IDX else "tof" + img = compute_saw_image( + self._sras, self._angle, self._threshold, + self._saw_pipeline, mode, self._apply_bg_sub) + else: + # DC channels: convert ADC counts → mV + adc_img = compute_dc_image(self._sras, self._angle, self._ch) + img = adc_to_mv(adc_img, + self._sras.ch_ymult_mv[self._ch], + self._sras.ch_yoff_adc[self._ch], + self._sras.ch_yzero_mv[self._ch]) + self.finished.emit(img) + except Exception as exc: + self.error.emit(str(exc)) + + +class TemplateBuildWorker(QObject): + """Background thread worker that calls SawPipeline.build_template().""" + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, pipeline: SawPipeline, waveforms: np.ndarray): + super().__init__() + self._pipeline = pipeline + self._waveforms = waveforms + + def run(self): + try: + self._pipeline.build_template(self._waveforms) + self.finished.emit() + except Exception as exc: + self.error.emit(str(exc)) + + +# --------------------------------------------------------------------------- +# Matplotlib canvases +# --------------------------------------------------------------------------- + +class ImageCanvas(FigureCanvasQTAgg): + pixel_clicked = pyqtSignal(int, int) # row_idx, frame_idx + + def __init__(self, parent=None): + fig = Figure(figsize=(7, 5), tight_layout=True) + self.ax = fig.add_subplot(111) + super().__init__(fig) + self.setParent(parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._extent = None + self._img_shape = None + self.mpl_connect("button_press_event", self._on_click) + + def show_image(self, img: np.ndarray, extent: list[float], cmap: str, + vmin: float, vmax: float, xlabel: str, ylabel: str, title: str, + colorbar_label: str = ""): + self.figure.clf() + self.ax = self.figure.add_subplot(111) + + self._extent = extent + self._img_shape = img.shape + + im = self.ax.imshow( + img, aspect="auto", origin="upper", + extent=extent, cmap=cmap, vmin=vmin, vmax=vmax, + interpolation="nearest", + ) + cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04) + if colorbar_label: + cb.set_label(colorbar_label) + + self.ax.set_xlabel(xlabel) + self.ax.set_ylabel(ylabel) + self.ax.set_title(title) + self.draw() + + def _on_click(self, event): + if event.inaxes is not self.ax or self._extent is None: + return + x0, x1, y_bot, y_top = self._extent + n_rows, n_frames = self._img_shape + col = int((event.xdata - x0) / (x1 - x0) * n_frames) + row = int((event.ydata - y_top) / (y_bot - y_top) * n_rows) + col = max(0, min(col, n_frames - 1)) + row = max(0, min(row, n_rows - 1)) + self.pixel_clicked.emit(row, col) + + +class WaveformCanvas(FigureCanvasQTAgg): + def __init__(self, parent=None): + fig = Figure(figsize=(8, 3), tight_layout=True) + self.ax_wave = fig.add_subplot(121) + self.ax_right = fig.add_subplot(122) + super().__init__(fig) + self.setParent(parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + def show_rf_waveform(self, sras: SrasFile, angle_idx: int, + row_idx: int, frame_idx: int, + apply_bg_sub: bool = True, + gate_start_ns: float | None = None, + gate_end_ns: float | None = None): + """CH1 RF: time-domain + FFT spectrum. + + If apply_bg_sub is True and sras.background is not None, the background + waveform is overlaid on the time-domain plot and the FFT is computed + on the subtracted signal. The unsubtracted FFT is also shown faintly + for comparison. + """ + waveform = sras.data[angle_idx, row_idx, CH1_IDX, frame_idx, :].astype(np.float32) + t_ns = sras.time_axis_ns() + f_mhz = sras.freq_axis_mhz() + dc3_val = sras.data[angle_idx, row_idx, CH3_IDX, frame_idx, :].astype(np.float32).mean() + dc4_val = sras.data[angle_idx, row_idx, CH4_IDX, frame_idx, :].astype(np.float32).mean() + + bg = sras.background if (apply_bg_sub and sras.background is not None) else None + waveform_plot = waveform - bg if bg is not None else waveform + + self.ax_wave.cla() + self.ax_right.cla() + + if bg is not None: + self.ax_wave.plot(t_ns, waveform, linewidth=0.5, color="#aaaaaa", + label="raw", zorder=1) + self.ax_wave.plot(t_ns, bg, linewidth=0.5, color="#e07030", + linestyle="--", label="background", zorder=2) + self.ax_wave.plot(t_ns, waveform_plot, linewidth=0.7, color="#4488cc", + label="subtracted", zorder=3) + self.ax_wave.legend(fontsize=7, loc="upper right") + else: + self.ax_wave.plot(t_ns, waveform, linewidth=0.7, color="#4488cc") + + # Draw gate boundaries if active + if gate_start_ns is not None: + self.ax_wave.axvline(gate_start_ns, color="#22cc44", linestyle="--", + linewidth=1.0, label=f"gate start {gate_start_ns:.0f} ns") + if gate_end_ns is not None: + self.ax_wave.axvline(gate_end_ns, color="#cc4422", linestyle="--", + linewidth=1.0, label=f"gate end {gate_end_ns:.0f} ns") + if gate_start_ns is not None or gate_end_ns is not None: + t_ns = sras.time_axis_ns() + lo = gate_start_ns if gate_start_ns is not None else t_ns[0] + hi = gate_end_ns if gate_end_ns is not None else t_ns[-1] + self.ax_wave.axvspan(t_ns[0], lo, alpha=0.10, color="#cc4422") + self.ax_wave.axvspan(hi, t_ns[-1], alpha=0.10, color="#cc4422") + + self.ax_wave.set_xlabel("Time (ns)") + self.ax_wave.set_ylabel("ADC counts") + bg_tag = " [bg sub]" if bg is not None else "" + self.ax_wave.set_title( + f"CH1 RF row={row_idx} frame={frame_idx}{bg_tag}\n" + f"CH3={dc3_val:.1f} CH4={dc4_val:.1f} " + f"({adc_to_mv(dc3_val, sras.ch_ymult_mv[CH3_IDX], sras.ch_yoff_adc[CH3_IDX], sras.ch_yzero_mv[CH3_IDX]):.2f} / " + f"{adc_to_mv(dc4_val, sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX], sras.ch_yzero_mv[CH4_IDX]):.2f} mV)", + fontsize=8, + ) + + # FFT of the (possibly subtracted) waveform + power_sub = np.abs(np.fft.rfft(waveform_plot)) ** 2 + power_sub[0] = 0.0 + peak_idx = int(np.argmax(power_sub)) + peak_mhz = f_mhz[peak_idx] + + if bg is not None: + # Also show the unsubtracted FFT for reference + power_raw = np.abs(np.fft.rfft(waveform)) ** 2 + power_raw[0] = 0.0 + self.ax_right.plot(f_mhz, power_raw, linewidth=0.5, color="#aaaaaa", + label="raw FFT", zorder=1) + + self.ax_right.plot(f_mhz, power_sub, linewidth=0.7, color="#4488cc", + label="subtracted FFT" if bg is not None else None, zorder=2) + self.ax_right.axvline(peak_mhz, color="tomato", linestyle="--", + linewidth=1.2, label=f"peak = {peak_mhz:.1f} MHz") + self.ax_right.set_xlabel("Frequency (MHz)") + self.ax_right.set_ylabel("Power (arb.)") + self.ax_right.set_title("FFT Power Spectrum") + self.ax_right.set_xlim(0, 500) + self.ax_right.legend(fontsize=8) + + self.draw() + + def show_dc_waveform(self, sras: SrasFile, angle_idx: int, ch_idx: int, + row_idx: int, frame_idx: int): + """CH3 or CH4 DC: time-domain + mean annotation.""" + waveform = sras.data[angle_idx, row_idx, ch_idx, frame_idx, :].astype(np.float32) + t_ns = sras.time_axis_ns() + mean_val = float(waveform.mean()) + mean_mv = adc_to_mv(mean_val, sras.ch_ymult_mv[ch_idx], sras.ch_yoff_adc[ch_idx], + sras.ch_yzero_mv[ch_idx]) + + self.ax_wave.cla() + self.ax_right.cla() + + self.ax_wave.plot(t_ns, waveform, linewidth=0.7, color="#4488cc") + self.ax_wave.axhline(mean_val, color="tomato", linestyle="--", + linewidth=1.2, label=f"mean = {mean_val:.2f} ADC") + self.ax_wave.set_xlabel("Time (ns)") + self.ax_wave.set_ylabel("ADC counts") + self.ax_wave.set_title( + f"{CH_NAMES[ch_idx]} DC row={row_idx} frame={frame_idx}" + ) + self.ax_wave.legend(fontsize=8) + + self.ax_right.text( + 0.5, 0.5, + f"DC mode\n\n" + f"mean = {mean_val:.3f} ADC\n" + f" = {mean_mv:.3f} mV", + ha="center", va="center", + transform=self.ax_right.transAxes, fontsize=11, + ) + self.ax_right.set_axis_off() + + self.draw() + + +# --------------------------------------------------------------------------- +# SAW diagnostic window +# --------------------------------------------------------------------------- + +class SawDiagnosticWindow(QMainWindow): + """6-panel matplotlib window showing every SAW pipeline stage for one pixel. + + Panels: + 1. Raw signal with zone shading (EMI gate / noise region / SAW window) + 2. After EMI gating (cosine taper) — same zone shading + 3. After bandpass filter (time domain) — zone shading + 4. Frequency spectrum of bandpass output — passband shading + raw PSD + 5. Matched filter output + Hilbert envelope + metrics — zone shading + 6. Shot-to-shot overlay (up to 20 frames from the same row) + """ + + # Zone colour constants (all panels use the same palette) + _C_EMI = "#e05030" # red — EMI gate + _C_NOISE = "#ccaa00" # amber — noise / inter-packet region + _C_SAW = "#30c060" # green — SAW window + + def __init__(self, sras: SrasFile, pipeline: SawPipeline, + angle_idx: int, row_idx: int, frame_idx: int, + parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.setWindowTitle( + f"SAW Diagnostics angle={angle_idx} row={row_idx} frame={frame_idx}") + self.resize(1400, 940) + + # Store references for refresh + self._sras = sras + self._pipeline = pipeline + self._angle_idx = angle_idx + self._row_idx = row_idx + self._frame_idx = frame_idx + + central = QWidget() + self.setCentralWidget(central) + vl = QVBoxLayout(central) + vl.setContentsMargins(4, 4, 4, 4) + vl.setSpacing(4) + + # Toolbar row: matplotlib toolbar + refresh button + toolbar_row = QHBoxLayout() + fig = Figure(figsize=(14, 9), tight_layout=True) + self._canvas = FigureCanvasQTAgg(fig) + mpl_toolbar = NavigationToolbar2QT(self._canvas, central) + toolbar_row.addWidget(mpl_toolbar, stretch=1) + btn_refresh = QPushButton("Re-apply filter & refresh PSDs") + btn_refresh.setToolTip( + "Re-run the current pipeline on this pixel and redraw all panels.\n" + "Use after rebuilding the template or changing pipeline parameters.") + btn_refresh.clicked.connect(self._on_refresh) + toolbar_row.addWidget(btn_refresh) + vl.addLayout(toolbar_row) + vl.addWidget(self._canvas) + + self._plot() + + # ------------------------------------------------------------------ + # Zone shading helper — call on any time-domain axes + # ------------------------------------------------------------------ + + def _shade_time_zones(self, ax, t_full: np.ndarray, + emi_end_ns: float, s0: float, s1: float, + show_legend: bool = False): + """Shade EMI gate, noise region, and SAW window on a time-domain axes.""" + t0, t_end = float(t_full[0]), float(t_full[-1]) + ax.axvspan(t0, emi_end_ns, alpha=0.15, color=self._C_EMI, + label=f"EMI gate 0–{emi_end_ns:.0f} ns") + if emi_end_ns < s0: + ax.axvspan(emi_end_ns, s0, alpha=0.08, color=self._C_NOISE, + label=f"noise {emi_end_ns:.0f}–{s0:.0f} ns") + ax.axvspan(s0, min(s1, t_end), alpha=0.10, color=self._C_SAW, + label=f"SAW {s0:.0f}–{s1:.0f} ns") + if show_legend: + ax.legend(fontsize=7, loc="upper right") + + # ------------------------------------------------------------------ + + def _on_refresh(self): + self._plot() + + def _plot(self): + sras = self._sras + pipeline = self._pipeline + angle_idx = self._angle_idx + row_idx = self._row_idx + frame_idx = self._frame_idx + + raw_adc = sras.data[angle_idx, row_idx, CH1_IDX, frame_idx, :].astype(np.float32) + t_full = sras.time_axis_ns() + result = pipeline.process_shot(raw_adc) + sr = result["sample_rate_hz"] + t_proc = np.arange(len(result["envelope"])) / sr * 1e9 + + # Collect up to 20 frames from the same row for the overlay panel + n_overlay = min(20, sras.n_frames) + overlay_idxs = np.linspace(0, sras.n_frames - 1, n_overlay, dtype=int) + overlays = [] + for fi in overlay_idxs: + sig = sras.data[angle_idx, row_idx, CH1_IDX, fi, :].astype(np.float32) + r = pipeline.process_shot(sig) + overlays.append(r["envelope"]) + + # Shot-to-shot peak amplitude variance + peak_amps = [float(e[np.argmax(e)]) if len(e) > 0 else 0.0 + for e in overlays] + peak_var = float(np.var(peak_amps)) + + fig = self._canvas.figure + fig.clf() + axes = fig.subplots(3, 2) + ax_raw, ax_gated = axes[0] + ax_filt, ax_spec = axes[1] + ax_mf, ax_over = axes[2] + + emi_end_ns = pipeline.emi_gate_ns + s0, s1 = pipeline.saw_window_ns + + # --- 1. Raw signal --- + ax_raw.plot(t_full, raw_adc, lw=0.6, color="#4488cc", zorder=3) + self._shade_time_zones(ax_raw, t_full, emi_end_ns, s0, s1, show_legend=True) + ax_raw.set_xlabel("Time (ns)") + ax_raw.set_ylabel("ADC counts") + ax_raw.set_title(f"1 — Raw signal (row={row_idx}, frame={frame_idx})") + + # --- 2. After EMI gating --- + ax_gated.plot(t_full[:len(result["gated"])], result["gated"], + lw=0.6, color="#cc8833", zorder=3) + self._shade_time_zones(ax_gated, t_full, emi_end_ns, s0, s1) + ax_gated.set_xlabel("Time (ns)") + ax_gated.set_ylabel("Amplitude") + ax_gated.set_title("2 — After EMI gating (cosine taper)") + + # --- 3. After bandpass --- + ax_filt.plot(t_full[:len(result["filtered"])], result["filtered"], + lw=0.6, color="#44aa44", zorder=3) + self._shade_time_zones(ax_filt, t_full, emi_end_ns, s0, s1) + ax_filt.set_xlabel("Time (ns)") + ax_filt.set_ylabel("Amplitude") + ax_filt.set_title( + f"3 — After bandpass ({pipeline.bp_lo_mhz:.0f}–{pipeline.bp_hi_mhz:.0f} MHz, " + f"6th-order Butterworth, zero-phase)") + + # --- 4. Frequency spectrum — raw PSD + post-bandpass PSD --- + raw_sig = raw_adc.astype(np.float64) + filt_sig = result["filtered"] + f_hz = np.fft.rfftfreq(len(filt_sig), d=1.0 / sras.sample_rate_hz) + f_mhz = f_hz / 1e6 + spec_raw = np.abs(np.fft.rfft(raw_sig, n=len(filt_sig))) ** 2 + spec_filt = np.abs(np.fft.rfft(filt_sig)) ** 2 + spec_raw[0] = 0.0 + spec_filt[0] = 0.0 + ax_spec.plot(f_mhz, spec_raw, lw=0.5, color="#aaaaaa", alpha=0.7, + label="raw PSD", zorder=1) + ax_spec.plot(f_mhz, spec_filt, lw=0.8, color="#44aa44", + label="bandpass PSD", zorder=2) + ax_spec.axvspan(pipeline.bp_lo_mhz, pipeline.bp_hi_mhz, + alpha=0.14, color=self._C_SAW, label="passband", zorder=0) + ax_spec.set_xlabel("Frequency (MHz)") + ax_spec.set_ylabel("Power (arb.)") + ax_spec.set_title("4 — FFT PSD: raw vs. after bandpass") + ax_spec.set_xlim(0, min(600.0, sras.sample_rate_hz / 2e6)) + ax_spec.legend(fontsize=7) + + # --- 5. Matched filter output + envelope --- + ax_mf.plot(t_proc, result["mf_output"], lw=0.5, color="#8855cc", + alpha=0.55, label="MF output", zorder=3) + ax_mf.plot(t_proc, result["envelope"], lw=1.3, color="#cc4488", + label="envelope (Hilbert)", zorder=4) + if pipeline.template is not None: + ax_mf.axvline(result["peak_time_ns"], color="#ffaa00", + linestyle="--", lw=1.2, zorder=5, + label=f"peak {result['peak_time_ns']:.1f} ns") + self._shade_time_zones(ax_mf, t_proc, emi_end_ns, s0, s1) + ax_mf.set_xlabel("Time (ns)") + ax_mf.set_ylabel("Amplitude") + ax_mf.set_title( + f"5 — Matched filter | " + f"A = {result['peak_amplitude']:.3f} | " + f"SNR = {result['snr']:.1f} | " + f"t = {result['peak_time_ns']:.1f} ns") + ax_mf.legend(fontsize=7) + + # --- 6. Shot-to-shot overlay --- + for env in overlays: + t_ov = np.arange(len(env)) / sr * 1e9 + ax_over.plot(t_ov, env, lw=0.5, alpha=0.45, color="#cc4488", zorder=3) + self._shade_time_zones(ax_over, t_proc, emi_end_ns, s0, s1) + ax_over.set_xlabel("Time (ns)") + ax_over.set_ylabel("MF envelope amplitude") + ax_over.set_title( + f"6 — Shot-to-shot overlay (row {row_idx}, {n_overlay} frames) | " + f"peak-amp variance = {peak_var:.4g}") + + # Bottom metrics bar + fig.text( + 0.5, 0.005, + f"Peak amplitude: {result['peak_amplitude']:.4g} | " + f"Arrival time: {result['peak_time_ns']:.2f} ns | " + f"SNR: {result['snr']:.1f} | " + f"Shot-to-shot peak-amp variance ({n_overlay} shots): {peak_var:.4g}", + ha="center", va="bottom", fontsize=9, + bbox=dict(boxstyle="round,pad=0.3", facecolor="#2a2a2a", alpha=0.85), + color="#e8e8e8", + ) + self._canvas.draw() + + +# --------------------------------------------------------------------------- +# Main window +# --------------------------------------------------------------------------- + +class SrasViewerWindow(QMainWindow): + def __init__(self, initial_path: str | None = None): + super().__init__() + self.setWindowTitle("SRAS Scan Viewer") + self.resize(1560, 840) + self.setAcceptDrops(True) + + self._sras: SrasFile | None = None + self._current_image: np.ndarray | None = None + self._current_angle: int = 0 + self._current_ch: int = 0 + self._load_thread: QThread | None = None + self._compute_thread: QThread | None = None + self._pending_angle: int = 0 + self._pending_ch: int = 0 + self._pending_threshold: float = 50.0 # mV + self._pending_grating_um: float = 12.5 # µm + self._pending_bg_sub: bool = True + self._pending_gate_enabled: bool = False + self._pending_gate_start: float = 0.0 + self._pending_gate_end: float = 200.0 + self._progress_dlg: QProgressDialog | None = None + + # SAW pipeline state + self._saw_pipeline: SawPipeline | None = None + self._template_thread: QThread | None = None + self._diag_window: SawDiagnosticWindow | None = None + self._last_row: int | None = None + self._last_frame: int | None = None + + self._build_ui() + + if initial_path: + self._load_file(initial_path) + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + root = QHBoxLayout(central) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(8) + + # ---- Left control panel ---------------------------------------- + panel = QWidget() + panel.setFixedWidth(260) + panel_layout = QVBoxLayout(panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.setSpacing(6) + root.addWidget(panel) + + # File + grp_file = QGroupBox("File") + fl = QVBoxLayout(grp_file) + self.btn_open = QPushButton("Open .sras…") + self.btn_open.clicked.connect(self._on_open) + self.lbl_filename = QLabel("No file loaded") + self.lbl_filename.setWordWrap(True) + self.lbl_filename.setStyleSheet("color: #888; font-size: 11px;") + fl.addWidget(self.btn_open) + fl.addWidget(self.lbl_filename) + panel_layout.addWidget(grp_file) + + # Scan info + grp_info = QGroupBox("Scan Info") + il = QVBoxLayout(grp_info) + self._info = {} + for key in ("Angles", "Rows", "Frames / row", "Samples / frame", + "Sample rate", "X start", "Pixel Δx", "Laser freq"): + lbl = QLabel(f"{key}: —") + lbl.setWordWrap(True) + lbl.setStyleSheet("font-size: 11px;") + il.addWidget(lbl) + self._info[key] = lbl + # Frame count warning (hidden until needed) + self.lbl_frame_warn = QLabel("") + self.lbl_frame_warn.setWordWrap(True) + self.lbl_frame_warn.setStyleSheet("color: #e07000; font-size: 11px;") + il.addWidget(self.lbl_frame_warn) + panel_layout.addWidget(grp_info) + + # View settings + grp_view = QGroupBox("View Settings") + vl = QVBoxLayout(grp_view) + + # Angle + ar = QHBoxLayout() + ar.addWidget(QLabel("Angle:")) + self.spin_angle = QSpinBox() + self.spin_angle.setRange(0, 0) + self.spin_angle.setEnabled(False) + self.spin_angle.valueChanged.connect(self._on_view_changed) + self.lbl_angle_deg = QLabel("—") + ar.addWidget(self.spin_angle) + ar.addWidget(self.lbl_angle_deg) + vl.addLayout(ar) + + # Channel + cr = QHBoxLayout() + cr.addWidget(QLabel("Channel:")) + self.combo_channel = QComboBox() + self.combo_channel.addItems(CH_LABELS) + self.combo_channel.setEnabled(False) + self.combo_channel.currentIndexChanged.connect(self._on_channel_changed) + cr.addWidget(self.combo_channel) + vl.addLayout(cr) + + # DC threshold (for RF / CH1 masking) + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet("color: #555;") + vl.addWidget(sep) + + self.grp_threshold = QGroupBox("RF Mask Threshold (CH1 only)") + tl = QVBoxLayout(self.grp_threshold) + thr_row = QHBoxLayout() + thr_row.addWidget(QLabel("DC threshold:")) + self.spin_threshold_mv = QDoubleSpinBox() + self.spin_threshold_mv.setRange(-500.0, 500.0) + self.spin_threshold_mv.setDecimals(3) + self.spin_threshold_mv.setSingleStep(0.025) + self.spin_threshold_mv.setSuffix(" mV") + self.spin_threshold_mv.setValue(50.0) + self.spin_threshold_mv.setEnabled(False) + self.spin_threshold_mv.valueChanged.connect(self._on_threshold_changed) + thr_row.addWidget(self.spin_threshold_mv) + tl.addLayout(thr_row) + self.lbl_threshold_adc = QLabel(f"≈ {mv_to_adc(50.0):.1f} ADC counts") # updated on file load + self.lbl_threshold_adc.setStyleSheet("font-size: 11px; color: #888;") + tl.addWidget(self.lbl_threshold_adc) + vl.addWidget(self.grp_threshold) + + # Background subtraction (v4 files only) + self.chk_bg_sub = QCheckBox("Background subtraction (CH1 only)") + self.chk_bg_sub.setChecked(True) + self.chk_bg_sub.setEnabled(False) + self.chk_bg_sub.setToolTip( + "Subtract the stored background waveform from each CH1 frame\n" + "before computing the FFT (v4 files only)." + ) + self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled) + vl.addWidget(self.chk_bg_sub) + + # Time gate (for FFT; CH1/velocity only) + self.grp_gate = QGroupBox("Time Gate (CH1 only)") + gl = QVBoxLayout(self.grp_gate) + self.chk_gate = QCheckBox("Enable time gate") + self.chk_gate.setChecked(False) + self.chk_gate.setEnabled(False) + self.chk_gate.setToolTip( + "Zero-out samples outside the specified time window before\n" + "computing the FFT (useful for isolating a specific acoustic packet)." + ) + self.chk_gate.toggled.connect(self._on_gate_toggled) + gl.addWidget(self.chk_gate) + + gate_start_row = QHBoxLayout() + gate_start_row.addWidget(QLabel("Start:")) + self.spin_gate_start = QDoubleSpinBox() + self.spin_gate_start.setRange(0.0, 100000.0) + self.spin_gate_start.setDecimals(1) + self.spin_gate_start.setSingleStep(10.0) + self.spin_gate_start.setSuffix(" ns") + self.spin_gate_start.setValue(50.0) + self.spin_gate_start.setEnabled(False) + self.spin_gate_start.valueChanged.connect(self._on_gate_changed) + gate_start_row.addWidget(self.spin_gate_start) + gl.addLayout(gate_start_row) + + gate_end_row = QHBoxLayout() + gate_end_row.addWidget(QLabel("End:")) + self.spin_gate_end = QDoubleSpinBox() + self.spin_gate_end.setRange(0.0, 100000.0) + self.spin_gate_end.setDecimals(1) + self.spin_gate_end.setSingleStep(10.0) + self.spin_gate_end.setSuffix(" ns") + self.spin_gate_end.setValue(200.0) + self.spin_gate_end.setEnabled(False) + self.spin_gate_end.valueChanged.connect(self._on_gate_changed) + gate_end_row.addWidget(self.spin_gate_end) + gl.addLayout(gate_end_row) + + vl.addWidget(self.grp_gate) + + # Velocity settings (visible only in velocity mode) + self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)") + vel_l = QVBoxLayout(self.grp_velocity) + grat_row = QHBoxLayout() + grat_row.addWidget(QLabel("Grating size:")) + self.spin_grating_um = QDoubleSpinBox() + self.spin_grating_um.setRange(0.1, 1000.0) + self.spin_grating_um.setDecimals(2) + self.spin_grating_um.setSingleStep(0.5) + self.spin_grating_um.setSuffix(" µm") + self.spin_grating_um.setValue(12.5) + self.spin_grating_um.setEnabled(False) + self.spin_grating_um.valueChanged.connect(self._on_grating_changed) + grat_row.addWidget(self.spin_grating_um) + vel_l.addLayout(grat_row) + self.lbl_velocity_formula = QLabel("v (m/s) = freq (MHz) × grating (µm)") + self.lbl_velocity_formula.setStyleSheet("font-size: 10px; color: #888;") + vel_l.addWidget(self.lbl_velocity_formula) + self.grp_velocity.setVisible(False) + vl.addWidget(self.grp_velocity) + + # Export + sep2 = QFrame() + sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet("color: #555;") + vl.addWidget(sep2) + self.btn_export_csv = QPushButton("Export Image as CSV…") + self.btn_export_csv.setEnabled(False) + self.btn_export_csv.setToolTip( + "Save the current CH1 image (one scan row per CSV line)." + ) + self.btn_export_csv.clicked.connect(self._on_export_csv) + vl.addWidget(self.btn_export_csv) + + panel_layout.addWidget(grp_view) + + # ---- SAW Pipeline panel ---------------------------------------- + grp_saw = QGroupBox("SAW Pipeline (CH1 only)") + sl = QVBoxLayout(grp_saw) + + # EMI gate end + emi_row = QHBoxLayout() + emi_row.addWidget(QLabel("EMI gate end:")) + self.spin_saw_emi_ns = QDoubleSpinBox() + self.spin_saw_emi_ns.setRange(1.0, 10000.0) + self.spin_saw_emi_ns.setDecimals(1) + self.spin_saw_emi_ns.setSingleStep(5.0) + self.spin_saw_emi_ns.setSuffix(" ns") + self.spin_saw_emi_ns.setValue(50.0) + emi_row.addWidget(self.spin_saw_emi_ns) + sl.addLayout(emi_row) + + # SAW window + sl.addWidget(QLabel("SAW window (ns):")) + saw_win_row = QHBoxLayout() + self.spin_saw_win_start = QDoubleSpinBox() + self.spin_saw_win_start.setRange(0.0, 100000.0) + self.spin_saw_win_start.setDecimals(1) + self.spin_saw_win_start.setSuffix(" ns") + self.spin_saw_win_start.setValue(80.0) + saw_win_row.addWidget(self.spin_saw_win_start) + saw_win_row.addWidget(QLabel("–")) + self.spin_saw_win_end = QDoubleSpinBox() + self.spin_saw_win_end.setRange(0.0, 100000.0) + self.spin_saw_win_end.setDecimals(1) + self.spin_saw_win_end.setSuffix(" ns") + self.spin_saw_win_end.setValue(350.0) + saw_win_row.addWidget(self.spin_saw_win_end) + sl.addLayout(saw_win_row) + + # Bandpass limits + sl.addWidget(QLabel("Bandpass (MHz):")) + bp_row = QHBoxLayout() + self.spin_saw_bp_lo = QDoubleSpinBox() + self.spin_saw_bp_lo.setRange(1.0, 3000.0) + self.spin_saw_bp_lo.setDecimals(1) + self.spin_saw_bp_lo.setSuffix(" MHz") + self.spin_saw_bp_lo.setValue(85.0) + bp_row.addWidget(self.spin_saw_bp_lo) + bp_row.addWidget(QLabel("–")) + self.spin_saw_bp_hi = QDoubleSpinBox() + self.spin_saw_bp_hi.setRange(1.0, 3000.0) + self.spin_saw_bp_hi.setDecimals(1) + self.spin_saw_bp_hi.setSuffix(" MHz") + self.spin_saw_bp_hi.setValue(200.0) + bp_row.addWidget(self.spin_saw_bp_hi) + sl.addLayout(bp_row) + + # Template shots + tmpl_row = QHBoxLayout() + tmpl_row.addWidget(QLabel("Template shots:")) + self.spin_saw_n_shots = QSpinBox() + self.spin_saw_n_shots.setRange(1, 10000) + self.spin_saw_n_shots.setValue(50) + tmpl_row.addWidget(self.spin_saw_n_shots) + sl.addLayout(tmpl_row) + + self.btn_build_template = QPushButton("Build Template") + self.btn_build_template.setEnabled(False) + self.btn_build_template.setToolTip( + "Average N shots (EMI-gated + bandpass-filtered) to form a\n" + "Hann-windowed template for the matched filter.") + self.btn_build_template.clicked.connect(self._on_build_template_clicked) + sl.addWidget(self.btn_build_template) + + self.lbl_saw_status = QLabel("No template") + self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #888;") + self.lbl_saw_status.setWordWrap(True) + sl.addWidget(self.lbl_saw_status) + + # ---- Apply matched filter controls ---- + sep_mf = QFrame() + sep_mf.setFrameShape(QFrame.Shape.HLine) + sep_mf.setStyleSheet("color: #555;") + sl.addWidget(sep_mf) + + sl.addWidget(QLabel("Apply matched filter:")) + + mf_mode_row = QHBoxLayout() + mf_mode_row.addWidget(QLabel("Output:")) + self.combo_mf_mode = QComboBox() + self.combo_mf_mode.addItems(["Amplitude", "Time-of-Flight"]) + self.combo_mf_mode.setEnabled(False) + mf_mode_row.addWidget(self.combo_mf_mode) + sl.addLayout(mf_mode_row) + + self.btn_apply_mf = QPushButton("Apply Filter → Image") + self.btn_apply_mf.setEnabled(False) + self.btn_apply_mf.setToolTip( + "Switch to the SAW matched-filter channel and compute the image.\n" + "Requires a template to be built first.") + self.btn_apply_mf.clicked.connect(self._on_apply_mf_clicked) + sl.addWidget(self.btn_apply_mf) + + panel_layout.addStretch() + + # Colormap + sep2 = QFrame() + sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet("color: #555;") + vl.addWidget(sep2) + + cmr = QHBoxLayout() + cmr.addWidget(QLabel("Colormap:")) + self.combo_cmap = QComboBox() + self.combo_cmap.addItems(CMAPS) + self.combo_cmap.setCurrentText("gray") + self.combo_cmap.setEnabled(False) + self.combo_cmap.currentIndexChanged.connect(self._on_view_changed) + cmr.addWidget(self.combo_cmap) + vl.addLayout(cmr) + + # Auto-scale + self.chk_auto = QCheckBox("Auto-scale colormap") + self.chk_auto.setChecked(True) + self.chk_auto.toggled.connect(self._on_autoscale_toggled) + vl.addWidget(self.chk_auto) + + for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")): + row = QHBoxLayout() + row.addWidget(QLabel(label)) + spin = QDoubleSpinBox() + spin.setRange(-1e9, 1e9) + spin.setDecimals(4) + spin.setEnabled(False) + spin.valueChanged.connect(self._on_manual_range_changed) + setattr(self, attr, spin) + row.addWidget(spin) + vl.addLayout(row) + + # ---- Right: image + waveform splitter -------------------------- + splitter = QSplitter(Qt.Orientation.Vertical) + root.addWidget(splitter, stretch=1) + + # Image canvas + img_widget = QWidget() + img_vl = QVBoxLayout(img_widget) + img_vl.setContentsMargins(0, 0, 0, 0) + self.image_canvas = ImageCanvas() + self.image_canvas.pixel_clicked.connect(self._on_pixel_clicked) + toolbar = NavigationToolbar2QT(self.image_canvas, img_widget) + img_vl.addWidget(toolbar) + img_vl.addWidget(self.image_canvas) + splitter.addWidget(img_widget) + + # Waveform inspector + wave_widget = QWidget() + wave_vl = QVBoxLayout(wave_widget) + wave_vl.setContentsMargins(0, 0, 0, 0) + self.lbl_wave_hint = QLabel( + "Click a pixel in the image above to inspect its waveform." + ) + self.lbl_wave_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.lbl_wave_hint.setStyleSheet("color: #888; font-size: 11px;") + self.wave_canvas = WaveformCanvas() + self.btn_saw_diag = QPushButton("Open SAW Diagnostics…") + self.btn_saw_diag.setEnabled(False) + self.btn_saw_diag.setToolTip( + "Show the 6-panel SAW pipeline diagnostic for the clicked pixel.\n" + "Requires a SAW template to be built first.") + self.btn_saw_diag.clicked.connect(self._on_open_diagnostics) + wave_vl.addWidget(self.lbl_wave_hint) + wave_vl.addWidget(self.btn_saw_diag) + wave_vl.addWidget(self.wave_canvas) + splitter.addWidget(wave_widget) + + splitter.setSizes([580, 250]) + + # ---- Right control panel (SAW pipeline) ---------------------------- + right_panel = QWidget() + right_panel.setFixedWidth(260) + right_panel_layout = QVBoxLayout(right_panel) + right_panel_layout.setContentsMargins(0, 0, 0, 0) + right_panel_layout.setSpacing(6) + right_panel_layout.addWidget(grp_saw) + right_panel_layout.addStretch() + root.addWidget(right_panel) + + self.statusBar().showMessage("Open an .sras file to begin.") + + # ------------------------------------------------------------------ + # Drag-and-drop + # ------------------------------------------------------------------ + + def dragEnterEvent(self, event): + urls = event.mimeData().urls() + if urls and urls[0].toLocalFile().lower().endswith(".sras"): + event.acceptProposedAction() + + def dropEvent(self, event): + self._load_file(event.mimeData().urls()[0].toLocalFile()) + + # ------------------------------------------------------------------ + # File loading + # ------------------------------------------------------------------ + + def _on_open(self): + path, _ = QFileDialog.getOpenFileName( + self, "Open SRAS File", "", "SRAS Files (*.sras);;All Files (*)" + ) + if path: + self._load_file(path) + + def _load_file(self, path: str): + if self._load_thread is not None: + return + self.btn_open.setEnabled(False) + self.statusBar().showMessage(f"Loading {Path(path).name}…") + self._show_progress(f"Loading {Path(path).name}…") + + self._load_worker = LoadWorker(path) + self._load_thread = QThread() + self._load_worker.moveToThread(self._load_thread) + self._load_thread.started.connect(self._load_worker.run) + self._load_worker.finished.connect(self._on_load_done) + self._load_worker.error.connect( + lambda msg: self.statusBar().showMessage(f"Error: {msg}") + ) + self._load_worker.finished.connect(self._load_thread.quit) + self._load_thread.finished.connect(lambda: setattr(self, "_load_thread", None)) + self._load_thread.start() + + def _on_load_done(self, sras): + self._close_progress() + self.btn_open.setEnabled(True) + if sras is None: + return + self._sras = sras + self._current_image = None + + s = sras + self.lbl_filename.setText(s.path.name) + self._info["Angles"].setText(f"Angles: {s.n_angles}") + self._info["Rows"].setText(f"Rows: {s.n_rows}") + self._info["Frames / row"].setText(f"Frames / row: {s.n_frames}") + self._info["Samples / frame"].setText(f"Samples / frame: {s.samples_per_frame}") + self._info["Sample rate"].setText(f"Sample rate: {s.sample_rate_hz/1e9:.4g} GS/s") + self._info["X start"].setText(f"X start: {s.x_start_mm:.4g} mm") + self._info["Pixel Δx"].setText(f"Pixel Δx: {s.pixel_x_mm*1e3:.3g} µm") + self._info["Laser freq"].setText(f"Laser freq: {s.laser_freq_hz/1e3:.4g} kHz") + + notes = [] + if s.frame_count_mismatch: + notes.append( + f"! Header n_frames={s.n_frames_header}, " + f"actual={s.n_frames} (scanner bug — corrected)" + ) + if s.background is not None: + notes.append(f"Background waveform: {len(s.background)} samples") + self.lbl_frame_warn.setText("\n".join(notes)) + + self.spin_angle.blockSignals(True) + self.spin_angle.setRange(0, max(0, s.n_angles - 1)) + self.spin_angle.setValue(0) + self.spin_angle.blockSignals(False) + + self._update_controls_enabled(True) + # Refresh the ADC-count label now that we have file calibration + self._on_threshold_changed(self.spin_threshold_mv.value()) + self._on_view_changed() + + # ------------------------------------------------------------------ + # Controls + # ------------------------------------------------------------------ + + def _update_controls_enabled(self, enabled: bool): + s = self._sras + self.spin_angle.setEnabled(enabled and s is not None and s.n_angles > 1) + self.combo_channel.setEnabled(enabled) + self.combo_cmap.setEnabled(enabled) + self.chk_auto.setEnabled(enabled) + manual = enabled and not self.chk_auto.isChecked() + self.spin_vmin.setEnabled(manual) + self.spin_vmax.setEnabled(manual) + ch_idx = self.combo_channel.currentIndex() + is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES + is_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX) + is_saw = enabled and ch_idx in SAW_MODES + # Threshold and bg-sub apply to all CH1 modes + self.spin_threshold_mv.setEnabled(is_ch1) + has_bg = enabled and s is not None and s.background is not None + self.chk_bg_sub.setEnabled(has_bg and is_ch1) + # Time gate only for legacy FFT modes (SAW pipeline has its own gating) + self.chk_gate.setEnabled(is_fft) + gate_active = is_fft and self.chk_gate.isChecked() + self.spin_gate_start.setEnabled(gate_active) + self.spin_gate_end.setEnabled(gate_active) + # Velocity grating spinbox + is_vel = enabled and ch_idx == VELOCITY_MODE_IDX + self.spin_grating_um.setEnabled(is_vel) + self.grp_velocity.setVisible(is_vel) + # SAW pipeline build button + has_file = enabled and s is not None + self.btn_build_template.setEnabled(has_file) + # Diagnostics button: need template + a clicked pixel + has_template = self._saw_pipeline is not None and self._saw_pipeline.template is not None + has_pixel = self._last_row is not None + self.btn_saw_diag.setEnabled(has_file and has_template and has_pixel) + # Apply filter button: need file + template + self.btn_apply_mf.setEnabled(has_file and has_template) + self.combo_mf_mode.setEnabled(has_file and has_template) + # CSV export: enabled when a CH1-derived image is displayed + self.btn_export_csv.setEnabled(is_ch1 and self._current_image is not None) + + def _on_channel_changed(self): + ch_idx = self.combo_channel.currentIndex() + has_file = self._sras is not None + is_ch1 = ch_idx in CH1_DERIVED_MODES + is_fft = ch_idx in (CH1_IDX, VELOCITY_MODE_IDX) + self.spin_threshold_mv.setEnabled(is_ch1 and has_file) + has_bg = has_file and self._sras.background is not None + self.chk_bg_sub.setEnabled(has_bg and is_ch1) + self.chk_gate.setEnabled(is_fft and has_file) + gate_active = is_fft and has_file and self.chk_gate.isChecked() + self.spin_gate_start.setEnabled(gate_active) + self.spin_gate_end.setEnabled(gate_active) + is_vel = ch_idx == VELOCITY_MODE_IDX + self.spin_grating_um.setEnabled(is_vel and has_file) + self.grp_velocity.setVisible(is_vel) + self.btn_export_csv.setEnabled(is_ch1 and has_file and self._current_image is not None) + self._on_view_changed() + + def _on_bg_sub_toggled(self): + if self._sras is not None: + if self.combo_channel.currentIndex() in CH1_DERIVED_MODES: + self._start_compute() + + def _on_gate_toggled(self, checked: bool): + self.spin_gate_start.setEnabled(checked) + self.spin_gate_end.setEnabled(checked) + if self._sras is not None: + ch_idx = self.combo_channel.currentIndex() + if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX): + self._start_compute() + + def _on_gate_changed(self): + if self._sras is not None and self.chk_gate.isChecked(): + ch_idx = self.combo_channel.currentIndex() + if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX): + self._start_compute() + + def _on_grating_changed(self): + if self._sras is not None and self.combo_channel.currentIndex() == VELOCITY_MODE_IDX: + self._start_compute() + + def _on_export_csv(self): + if self._current_image is None or self._sras is None: + return + ch_idx = self._current_ch + angle = self._current_angle + ch_name = CH_NAMES[ch_idx] + default_name = ( + f"{self._sras.path.stem}_angle{angle}_{ch_name}.csv" + ) + path, _ = QFileDialog.getSaveFileName( + self, "Export Image as CSV", + str(self._sras.path.parent / default_name), + "CSV files (*.csv);;All files (*)", + ) + if not path: + return + np.savetxt(path, self._current_image, delimiter=",", fmt="%.6g") + self.statusBar().showMessage(f"Exported {Path(path).name}") + + def _on_threshold_changed(self, mv: float): + if self._sras is not None: + ymult = self._sras.ch_ymult_mv[CH4_IDX] + yoff = self._sras.ch_yoff_adc[CH4_IDX] + yzero = self._sras.ch_yzero_mv[CH4_IDX] + else: + ymult, yoff, yzero = _FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC, 0.0 + self.lbl_threshold_adc.setText(f"≈ {mv_to_adc(mv, ymult, yoff, yzero):.1f} ADC counts") + if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES: + self._start_compute() + + def _on_autoscale_toggled(self, checked: bool): + manual = not checked + self.spin_vmin.setEnabled(manual and self._sras is not None) + self.spin_vmax.setEnabled(manual and self._sras is not None) + if self._sras is not None and self._current_image is not None: + self._redraw_image(self._current_image) + + def _on_manual_range_changed(self): + if not self.chk_auto.isChecked() and self._current_image is not None: + self._redraw_image(self._current_image) + + def _on_view_changed(self): + if self._sras is None: + return + idx = self.spin_angle.value() + self.lbl_angle_deg.setText(f"({self._sras.angles_deg[idx]:.1f}°)") + self._start_compute() + + # ------------------------------------------------------------------ + # Computation + # ------------------------------------------------------------------ + + def _start_compute(self): + if self._sras is None: + return + if self._compute_thread is not None: + return # re-check in _on_compute_thread_finished + + angle_idx = self.spin_angle.value() + ch_idx = self.combo_channel.currentIndex() + threshold_mv = self.spin_threshold_mv.value() + grating_um = self.spin_grating_um.value() + apply_bg_sub = self.chk_bg_sub.isChecked() + gate_enabled = self.chk_gate.isChecked() + gate_start = self.spin_gate_start.value() if gate_enabled else None + gate_end = self.spin_gate_end.value() if gate_enabled else None + + self._pending_angle = angle_idx + self._pending_ch = ch_idx + self._pending_threshold = threshold_mv + self._pending_grating_um = grating_um + self._pending_bg_sub = apply_bg_sub + self._pending_gate_enabled = gate_enabled + self._pending_gate_start = self.spin_gate_start.value() + self._pending_gate_end = self.spin_gate_end.value() + + self.statusBar().showMessage("Computing image…") + self._show_progress("Computing image…") + + self._compute_worker = ComputeWorker( + self._sras, angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub, + gate_start_ns=gate_start, gate_end_ns=gate_end, + saw_pipeline=self._saw_pipeline, + ) + self._compute_thread = QThread() + self._compute_worker.moveToThread(self._compute_thread) + self._compute_thread.started.connect(self._compute_worker.run) + self._compute_worker.finished.connect(self._on_compute_done) + self._compute_worker.error.connect( + lambda msg: self.statusBar().showMessage(f"Compute error: {msg}") + ) + self._compute_worker.finished.connect(self._compute_thread.quit) + self._compute_thread.finished.connect(self._on_compute_thread_finished) + self._compute_thread.start() + + def _on_compute_thread_finished(self): + self._compute_thread = None + angle_idx = self.spin_angle.value() + ch_idx = self.combo_channel.currentIndex() + threshold_mv = self.spin_threshold_mv.value() + grating_um = self.spin_grating_um.value() + apply_bg_sub = self.chk_bg_sub.isChecked() + gate_enabled = self.chk_gate.isChecked() + if (angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub, + gate_enabled, + self.spin_gate_start.value(), self.spin_gate_end.value()) != ( + self._pending_angle, self._pending_ch, + self._pending_threshold, self._pending_grating_um, + self._pending_bg_sub, + self._pending_gate_enabled, + self._pending_gate_start, self._pending_gate_end): + self._start_compute() + + def _on_compute_done(self, img: np.ndarray): + self._close_progress() + self._current_image = img + self._current_angle = self._pending_angle + self._current_ch = self._pending_ch + self.btn_export_csv.setEnabled(self._pending_ch in CH1_DERIVED_MODES) + self._redraw_image(img) + + def _redraw_image(self, img: np.ndarray): + s = self._sras + x_axis = s.x_axis_mm() + y_axis = s.y_positions_mm + dx = x_axis[1] - x_axis[0] if len(x_axis) > 1 else s.pixel_x_mm + dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0 + + extent = [ + x_axis[0] - dx / 2, + x_axis[-1] + dx / 2, + y_axis[-1] + dy / 2, + y_axis[0] - dy / 2, + ] + + if self.chk_auto.isChecked(): + vmin, vmax = float(img.min()), float(img.max()) + for spin, val in ((self.spin_vmin, vmin), (self.spin_vmax, vmax)): + spin.blockSignals(True) + spin.setValue(val) + spin.blockSignals(False) + else: + vmin = self.spin_vmin.value() + vmax = self.spin_vmax.value() + + ch_idx = self._current_ch + angle_deg = s.angles_deg[self._current_angle] + ch_label = CH_LABELS[ch_idx] + + if ch_idx == CH1_IDX: + mode_str = "RF" + unit = "Peak frequency (MHz)" + colorbar_label = "MHz" + elif ch_idx == VELOCITY_MODE_IDX: + grating = self.spin_grating_um.value() + mode_str = "Velocity" + unit = "Velocity (m/s)" + colorbar_label = "m/s" + ch_label = f"Velocity [grating={grating:.2f} µm]" + elif ch_idx == SAW_MODE_AMP_IDX: + mode_str = "SAW-AMP" + unit = "MF envelope peak (arb.)" + colorbar_label = "amplitude" + elif ch_idx == SAW_MODE_TOF_IDX: + mode_str = "SAW-TOF" + unit = "SAW arrival time (ns)" + colorbar_label = "ns" + else: + mode_str = "DC" + unit = "DC mean (mV)" + colorbar_label = "mV" + + title = f"{CH_NAMES[ch_idx]} | {mode_str} | {angle_deg:.1f}°" + + self.image_canvas.show_image( + img, extent, + cmap=self.combo_cmap.currentText(), + vmin=vmin, vmax=vmax, + xlabel="X (mm)", ylabel="Y (mm)", + title=title, + colorbar_label=colorbar_label, + ) + self.statusBar().showMessage( + f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° " + f"| {img.shape[1]} × {img.shape[0]} px | {unit}" + ) + + # ------------------------------------------------------------------ + # Pixel inspector + # ------------------------------------------------------------------ + + def _on_pixel_clicked(self, row_idx: int, frame_idx: int): + if self._sras is None or self._current_image is None: + return + self._last_row = row_idx + self._last_frame = frame_idx + self.lbl_wave_hint.hide() + ch_idx = self._current_ch + if ch_idx in CH1_DERIVED_MODES: + gate_enabled = self.chk_gate.isChecked() and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX) + self.wave_canvas.show_rf_waveform( + self._sras, self._current_angle, row_idx, frame_idx, + apply_bg_sub=self.chk_bg_sub.isChecked(), + gate_start_ns=self.spin_gate_start.value() if gate_enabled else None, + gate_end_ns=self.spin_gate_end.value() if gate_enabled else None, + ) + else: + self.wave_canvas.show_dc_waveform( + self._sras, self._current_angle, ch_idx, row_idx, frame_idx + ) + # Update diagnostics button availability + has_template = (self._saw_pipeline is not None and + self._saw_pipeline.template is not None) + self.btn_saw_diag.setEnabled( + self._sras is not None and has_template and True) + + # ------------------------------------------------------------------ + # SAW pipeline management + # ------------------------------------------------------------------ + + def _on_build_template_clicked(self): + if self._sras is None or self._template_thread is not None: + return + + # (Re-)create pipeline with current settings + sr = self._sras.sample_rate_hz + self._saw_pipeline = SawPipeline( + sample_rate_hz = sr, + emi_gate_ns = self.spin_saw_emi_ns.value(), + bp_lo_mhz = self.spin_saw_bp_lo.value(), + bp_hi_mhz = self.spin_saw_bp_hi.value(), + saw_window_ns = (self.spin_saw_win_start.value(), + self.spin_saw_win_end.value()), + decimate_enable = False, + ) + + angle_idx = self.spin_angle.value() + + if self._last_row is not None and self._last_frame is not None: + # Build from the single selected pixel's waveform + waveforms = self._sras.data[ + angle_idx, self._last_row, CH1_IDX, + self._last_frame:self._last_frame + 1, : + ].astype(np.float32) + n_shots = 1 + src_desc = f"selected pixel (row={self._last_row}, frame={self._last_frame})" + else: + # No pixel selected — sample N shots spread across the whole scan + n_shots = min(self.spin_saw_n_shots.value(), + self._sras.n_frames * self._sras.n_rows) + waveforms_all = self._sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32) + waveforms_flat = waveforms_all.reshape(-1, waveforms_all.shape[-1]) + indices = np.linspace(0, len(waveforms_flat) - 1, n_shots, dtype=int) + waveforms = waveforms_flat[indices] + src_desc = f"{n_shots} shots (full scan)" + + if (self._sras.background is not None and self.chk_bg_sub.isChecked()): + waveforms = waveforms - self._sras.background[np.newaxis, :] + + self.btn_build_template.setEnabled(False) + self.lbl_saw_status.setText(f"Building template from {src_desc}…") + self._show_progress("Building SAW template…") + + self._template_worker = TemplateBuildWorker(self._saw_pipeline, waveforms) + self._template_thread = QThread() + self._template_worker.moveToThread(self._template_thread) + self._template_thread.started.connect(self._template_worker.run) + self._template_worker.finished.connect(self._on_template_built) + self._template_worker.error.connect(self._on_template_error) + self._template_worker.finished.connect(self._template_thread.quit) + self._template_worker.error.connect(self._template_thread.quit) + self._template_thread.finished.connect( + lambda: setattr(self, "_template_thread", None)) + self._template_thread.start() + + def _on_template_built(self): + self._close_progress() + self.btn_build_template.setEnabled(True) + if self._last_row is not None and self._last_frame is not None: + src = f"pixel row={self._last_row} frame={self._last_frame}" + else: + src = f"{self.spin_saw_n_shots.value()} shots" + self.lbl_saw_status.setText( + f"Template ready ({src})\n" + f"EMI gate: {self.spin_saw_emi_ns.value():.0f} ns " + f"BP: {self.spin_saw_bp_lo.value():.0f}–{self.spin_saw_bp_hi.value():.0f} MHz") + self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #44cc66;") + has_pixel = self._last_row is not None + self.btn_saw_diag.setEnabled(self._sras is not None and has_pixel) + self.btn_apply_mf.setEnabled(True) + self.combo_mf_mode.setEnabled(True) + + def _on_template_error(self, msg: str): + self._close_progress() + self.btn_build_template.setEnabled(True) + self.lbl_saw_status.setText(f"Error: {msg}") + self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #e05030;") + + def _on_open_diagnostics(self): + if (self._sras is None or self._saw_pipeline is None or + self._last_row is None): + return + if self._diag_window is not None: + try: + self._diag_window.close() + except RuntimeError: + pass # C++ object already deleted (user closed the window) + self._diag_window = None + self._diag_window = SawDiagnosticWindow( + self._sras, self._saw_pipeline, + self._current_angle, self._last_row, self._last_frame, + parent=None, # free-floating window + ) + # Clear our reference when the user closes the window so we never + # call into a deleted C++ object again. + self._diag_window.destroyed.connect( + lambda: setattr(self, "_diag_window", None)) + self._diag_window.show() + + def _on_apply_mf_clicked(self): + if self._sras is None or self._saw_pipeline is None: + return + if self._saw_pipeline.template is None: + self.statusBar().showMessage( + "No template built yet — use 'Build Template' first.") + return + target_ch = (SAW_MODE_AMP_IDX + if self.combo_mf_mode.currentIndex() == 0 + else SAW_MODE_TOF_IDX) + self.combo_channel.blockSignals(True) + self.combo_channel.setCurrentIndex(target_ch) + self.combo_channel.blockSignals(False) + self._on_channel_changed() + + # ------------------------------------------------------------------ + # Progress dialog helpers + # ------------------------------------------------------------------ + + def _show_progress(self, message: str): + if self._progress_dlg is not None: + self._progress_dlg.setLabelText(message) + return + dlg = QProgressDialog(message, "", 0, 0, self) + dlg.setWindowTitle("Please wait…") + dlg.setCancelButton(None) + dlg.setWindowModality(Qt.WindowModality.WindowModal) + dlg.setMinimumDuration(300) # only appears if operation takes > 300 ms + dlg.show() + self._progress_dlg = dlg + + def _close_progress(self): + if self._progress_dlg is not None: + self._progress_dlg.close() + self._progress_dlg = None + + # ------------------------------------------------------------------ + + def closeEvent(self, event): + for attr in ("_load_thread", "_compute_thread", "_template_thread"): + t = getattr(self, attr, None) + if t is not None: + t.quit() + t.wait(2000) + if self._diag_window is not None: + try: + self._diag_window.close() + except RuntimeError: + pass + super().closeEvent(event) + + +# --------------------------------------------------------------------------- + +def main(): + app = QApplication(sys.argv) + initial = sys.argv[1] if len(sys.argv) > 1 else None + window = SrasViewerWindow(initial_path=initial) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/sras_viewer_requirements.txt b/sras_viewer_requirements.txt new file mode 100644 index 0000000..05adf9a --- /dev/null +++ b/sras_viewer_requirements.txt @@ -0,0 +1,3 @@ +PyQt6==6.10.2 +numpy==2.4.1 +matplotlib==3.10.8 diff --git a/ui_mainwindow.py b/ui_mainwindow.py new file mode 100644 index 0000000..5a1b11a --- /dev/null +++ b/ui_mainwindow.py @@ -0,0 +1,1321 @@ +# Form implementation generated from reading ui file '/opt/scanengine-3/sc3-new.ui' +# +# Created by: PyQt6 UI code generator 6.10.2 +# +# WARNING: Any manual changes made to this file will be lost when pyuic6 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt6 import QtCore, QtGui, QtWidgets + + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(1312, 1036) + MainWindow.setStyleSheet("QMainWindow {\n" +" background-color: #000;\n" +" color: #FFF\n" +"}\n" +"QWidget {\n" +" background-color: #000;\n" +" color: #FFF;\n" +" font-family: \"Space Grotesk\", sans-serif\n" +"}\n" +"QLabel {\n" +" color: #FFF\n" +"}\n" +"QTabWidget {\n" +" border: 2px solid #696773;\n" +"}\n" +"") + self.centralwidget = QtWidgets.QWidget(parent=MainWindow) + self.centralwidget.setObjectName("centralwidget") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.centralwidget) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.stackedWidget = QtWidgets.QStackedWidget(parent=self.centralwidget) + self.stackedWidget.setObjectName("stackedWidget") + self.start_page = QtWidgets.QWidget() + self.start_page.setObjectName("start_page") + self.verticalLayout = QtWidgets.QVBoxLayout(self.start_page) + self.verticalLayout.setObjectName("verticalLayout") + self.vl_mainmenu = QtWidgets.QVBoxLayout() + self.vl_mainmenu.setObjectName("vl_mainmenu") + self.label = QtWidgets.QLabel(parent=self.start_page) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(48) + self.label.setFont(font) + self.label.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label.setObjectName("label") + self.vl_mainmenu.addWidget(self.label) + self.label_2 = QtWidgets.QLabel(parent=self.start_page) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(14) + self.label_2.setFont(font) + self.label_2.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_2.setObjectName("label_2") + self.vl_mainmenu.addWidget(self.label_2) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.vl_mainmenu.addItem(spacerItem) + self.start_new_scan_btn = QtWidgets.QPushButton(parent=self.start_page) + self.start_new_scan_btn.setObjectName("start_new_scan_btn") + self.vl_mainmenu.addWidget(self.start_new_scan_btn) + self.resume_scan_btn = QtWidgets.QPushButton(parent=self.start_page) + self.resume_scan_btn.setObjectName("resume_scan_btn") + self.vl_mainmenu.addWidget(self.resume_scan_btn) + self.edit_options_btn = QtWidgets.QPushButton(parent=self.start_page) + self.edit_options_btn.setObjectName("edit_options_btn") + self.vl_mainmenu.addWidget(self.edit_options_btn) + spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.vl_mainmenu.addItem(spacerItem1) + self.verticalLayout.addLayout(self.vl_mainmenu) + self.stackedWidget.addWidget(self.start_page) + self.options_page = QtWidgets.QWidget() + self.options_page.setObjectName("options_page") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.options_page) + self.verticalLayout_3.setObjectName("verticalLayout_3") + self.gridLayout = QtWidgets.QGridLayout() + self.gridLayout.setObjectName("gridLayout") + self.verticalLayout_4 = QtWidgets.QVBoxLayout() + self.verticalLayout_4.setObjectName("verticalLayout_4") + self.label_3 = QtWidgets.QLabel(parent=self.options_page) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(32) + self.label_3.setFont(font) + self.label_3.setObjectName("label_3") + self.verticalLayout_4.addWidget(self.label_3) + self.configuration_tabwidget = QtWidgets.QTabWidget(parent=self.options_page) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(14) + self.configuration_tabwidget.setFont(font) + self.configuration_tabwidget.setObjectName("configuration_tabwidget") + self.kinematics_tab = QtWidgets.QWidget() + self.kinematics_tab.setObjectName("kinematics_tab") + self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.kinematics_tab) + self.verticalLayout_5.setObjectName("verticalLayout_5") + self.label_65 = QtWidgets.QLabel(parent=self.kinematics_tab) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(14) + self.label_65.setFont(font) + self.label_65.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft) + self.label_65.setObjectName("label_65") + self.verticalLayout_5.addWidget(self.label_65) + self.gridLayout_2 = QtWidgets.QGridLayout() + self.gridLayout_2.setObjectName("gridLayout_2") + spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_2.addItem(spacerItem2, 2, 1, 1, 1) + self.label_15 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_15.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_15.setObjectName("label_15") + self.gridLayout_2.addWidget(self.label_15, 9, 3, 1, 1) + self.label_14 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_14.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_14.setObjectName("label_14") + self.gridLayout_2.addWidget(self.label_14, 9, 0, 1, 1) + self.label_11 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_11.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_11.setObjectName("label_11") + self.gridLayout_2.addWidget(self.label_11, 4, 0, 1, 1) + self.label_6 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_6.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_6.setObjectName("label_6") + self.gridLayout_2.addWidget(self.label_6, 0, 3, 1, 1) + self.label_9 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_9.setObjectName("label_9") + self.gridLayout_2.addWidget(self.label_9, 1, 1, 1, 1) + self.label_4 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_4.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_4.setObjectName("label_4") + self.gridLayout_2.addWidget(self.label_4, 0, 0, 1, 1) + self.scan_accel_edit = QtWidgets.QLineEdit(parent=self.kinematics_tab) + self.scan_accel_edit.setObjectName("scan_accel_edit") + self.gridLayout_2.addWidget(self.scan_accel_edit, 0, 4, 1, 1) + self.optical_axis_x_edit = QtWidgets.QLineEdit(parent=self.kinematics_tab) + self.optical_axis_x_edit.setObjectName("optical_axis_x_edit") + self.gridLayout_2.addWidget(self.optical_axis_x_edit, 9, 1, 1, 1) + spacerItem3 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_2.addItem(spacerItem3, 7, 0, 1, 1) + self.stage_trigger_combo = QtWidgets.QComboBox(parent=self.kinematics_tab) + self.stage_trigger_combo.setObjectName("stage_trigger_combo") + self.gridLayout_2.addWidget(self.stage_trigger_combo, 4, 1, 1, 1) + self.label_10 = QtWidgets.QLabel(parent=self.kinematics_tab) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setBold(False) + font.setItalic(True) + font.setUnderline(True) + self.label_10.setFont(font) + self.label_10.setObjectName("label_10") + self.gridLayout_2.addWidget(self.label_10, 1, 2, 1, 3) + self.stage_serial_edit = QtWidgets.QLineEdit(parent=self.kinematics_tab) + self.stage_serial_edit.setObjectName("stage_serial_edit") + self.gridLayout_2.addWidget(self.stage_serial_edit, 5, 1, 1, 1) + self.label_13 = QtWidgets.QLabel(parent=self.kinematics_tab) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(14) + self.label_13.setFont(font) + self.label_13.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft) + self.label_13.setObjectName("label_13") + self.gridLayout_2.addWidget(self.label_13, 8, 0, 1, 2) + self.label_12 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_12.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_12.setObjectName("label_12") + self.gridLayout_2.addWidget(self.label_12, 5, 0, 1, 1) + self.label_7 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_7.setObjectName("label_7") + self.gridLayout_2.addWidget(self.label_7, 0, 5, 1, 1) + self.stage_test_connection_btn = QtWidgets.QPushButton(parent=self.kinematics_tab) + self.stage_test_connection_btn.setObjectName("stage_test_connection_btn") + self.gridLayout_2.addWidget(self.stage_test_connection_btn, 5, 3, 1, 1) + self.scan_velocity_edit = QtWidgets.QLineEdit(parent=self.kinematics_tab) + self.scan_velocity_edit.setObjectName("scan_velocity_edit") + self.gridLayout_2.addWidget(self.scan_velocity_edit, 0, 1, 1, 1) + self.label_8 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_8.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_8.setObjectName("label_8") + self.gridLayout_2.addWidget(self.label_8, 1, 0, 1, 1) + self.label_5 = QtWidgets.QLabel(parent=self.kinematics_tab) + self.label_5.setObjectName("label_5") + self.gridLayout_2.addWidget(self.label_5, 0, 2, 1, 1) + self.optical_axis_y_edit = QtWidgets.QLineEdit(parent=self.kinematics_tab) + self.optical_axis_y_edit.setObjectName("optical_axis_y_edit") + self.gridLayout_2.addWidget(self.optical_axis_y_edit, 9, 4, 1, 1) + spacerItem4 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_2.addItem(spacerItem4, 10, 1, 1, 1) + self.label_64 = QtWidgets.QLabel(parent=self.kinematics_tab) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(14) + self.label_64.setFont(font) + self.label_64.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft) + self.label_64.setObjectName("label_64") + self.gridLayout_2.addWidget(self.label_64, 3, 0, 1, 2) + self.verticalLayout_5.addLayout(self.gridLayout_2) + self.configuration_tabwidget.addTab(self.kinematics_tab, "") + self.detection_tab = QtWidgets.QWidget() + self.detection_tab.setObjectName("detection_tab") + self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.detection_tab) + self.verticalLayout_6.setObjectName("verticalLayout_6") + self.gridLayout_3 = QtWidgets.QGridLayout() + self.gridLayout_3.setObjectName("gridLayout_3") + self.label_17 = QtWidgets.QLabel(parent=self.detection_tab) + self.label_17.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_17.setObjectName("label_17") + self.gridLayout_3.addWidget(self.label_17, 1, 1, 1, 1) + self.detection_diode_label = QtWidgets.QLabel(parent=self.detection_tab) + self.detection_diode_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.detection_diode_label.setObjectName("detection_diode_label") + self.gridLayout_3.addWidget(self.detection_diode_label, 2, 3, 1, 1) + self.label_19 = QtWidgets.QLabel(parent=self.detection_tab) + self.label_19.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_19.setObjectName("label_19") + self.gridLayout_3.addWidget(self.label_19, 1, 3, 1, 1) + spacerItem5 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_3.addItem(spacerItem5, 3, 0, 1, 1) + self.detection_serial_num_label = QtWidgets.QLabel(parent=self.detection_tab) + self.detection_serial_num_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.detection_serial_num_label.setObjectName("detection_serial_num_label") + self.gridLayout_3.addWidget(self.detection_serial_num_label, 2, 0, 1, 1) + self.detection_test_btn = QtWidgets.QPushButton(parent=self.detection_tab) + self.detection_test_btn.setObjectName("detection_test_btn") + self.gridLayout_3.addWidget(self.detection_test_btn, 0, 0, 1, 2) + self.label_18 = QtWidgets.QLabel(parent=self.detection_tab) + self.label_18.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_18.setObjectName("label_18") + self.gridLayout_3.addWidget(self.label_18, 1, 2, 1, 1) + self.label_21 = QtWidgets.QLabel(parent=self.detection_tab) + self.label_21.setObjectName("label_21") + self.gridLayout_3.addWidget(self.label_21, 4, 0, 1, 1) + self.label_16 = QtWidgets.QLabel(parent=self.detection_tab) + self.label_16.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_16.setObjectName("label_16") + self.gridLayout_3.addWidget(self.label_16, 1, 0, 1, 1) + self.label_22 = QtWidgets.QLabel(parent=self.detection_tab) + self.label_22.setObjectName("label_22") + self.gridLayout_3.addWidget(self.label_22, 4, 2, 1, 1) + self.detection_power_edit = QtWidgets.QLineEdit(parent=self.detection_tab) + self.detection_power_edit.setMaximumSize(QtCore.QSize(200, 16777215)) + self.detection_power_edit.setObjectName("detection_power_edit") + self.gridLayout_3.addWidget(self.detection_power_edit, 4, 1, 1, 1) + self.detection_interlock_label = QtWidgets.QLabel(parent=self.detection_tab) + self.detection_interlock_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.detection_interlock_label.setObjectName("detection_interlock_label") + self.gridLayout_3.addWidget(self.detection_interlock_label, 2, 1, 1, 1) + self.detection_keyswitch_label = QtWidgets.QLabel(parent=self.detection_tab) + self.detection_keyswitch_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.detection_keyswitch_label.setObjectName("detection_keyswitch_label") + self.gridLayout_3.addWidget(self.detection_keyswitch_label, 2, 2, 1, 1) + self.detection_temperature_label = QtWidgets.QLabel(parent=self.detection_tab) + self.detection_temperature_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.detection_temperature_label.setObjectName("detection_temperature_label") + self.gridLayout_3.addWidget(self.detection_temperature_label, 2, 4, 1, 1) + self.label_20 = QtWidgets.QLabel(parent=self.detection_tab) + self.label_20.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_20.setObjectName("label_20") + self.gridLayout_3.addWidget(self.label_20, 1, 4, 1, 1) + spacerItem6 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_3.addItem(spacerItem6, 5, 0, 1, 1) + self.verticalLayout_6.addLayout(self.gridLayout_3) + self.configuration_tabwidget.addTab(self.detection_tab, "") + self.generation_tab = QtWidgets.QWidget() + self.generation_tab.setObjectName("generation_tab") + self.verticalLayout_7 = QtWidgets.QVBoxLayout(self.generation_tab) + self.verticalLayout_7.setObjectName("verticalLayout_7") + self.gridLayout_4 = QtWidgets.QGridLayout() + self.gridLayout_4.setObjectName("gridLayout_4") + self.label_30 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_30.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_30.setObjectName("label_30") + self.gridLayout_4.addWidget(self.label_30, 4, 1, 1, 1) + self.diode_pump_current_edit = QtWidgets.QLineEdit(parent=self.generation_tab) + self.diode_pump_current_edit.setMaximumSize(QtCore.QSize(400, 16777215)) + self.diode_pump_current_edit.setObjectName("diode_pump_current_edit") + self.gridLayout_4.addWidget(self.diode_pump_current_edit, 2, 1, 1, 2) + self.label_26 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_26.setObjectName("label_26") + self.gridLayout_4.addWidget(self.label_26, 2, 0, 1, 1) + self.label_23 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_23.setObjectName("label_23") + self.gridLayout_4.addWidget(self.label_23, 0, 0, 1, 1) + self.refresh_serial_ports_btn = QtWidgets.QPushButton(parent=self.generation_tab) + self.refresh_serial_ports_btn.setObjectName("refresh_serial_ports_btn") + self.gridLayout_4.addWidget(self.refresh_serial_ports_btn, 0, 3, 1, 1) + self.label_27 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_27.setObjectName("label_27") + self.gridLayout_4.addWidget(self.label_27, 2, 3, 1, 1) + self.label_33 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_33.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_33.setObjectName("label_33") + self.gridLayout_4.addWidget(self.label_33, 4, 4, 1, 1) + self.label_31 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_31.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_31.setObjectName("label_31") + self.gridLayout_4.addWidget(self.label_31, 4, 2, 1, 1) + self.label_32 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_32.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_32.setObjectName("label_32") + self.gridLayout_4.addWidget(self.label_32, 4, 3, 1, 1) + self.generation_connect_button = QtWidgets.QPushButton(parent=self.generation_tab) + self.generation_connect_button.setObjectName("generation_connect_button") + self.gridLayout_4.addWidget(self.generation_connect_button, 0, 4, 1, 1) + self.comboBox = QtWidgets.QComboBox(parent=self.generation_tab) + self.comboBox.setObjectName("comboBox") + self.gridLayout_4.addWidget(self.comboBox, 0, 1, 1, 2) + self.label_35 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_35.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_35.setObjectName("label_35") + self.gridLayout_4.addWidget(self.label_35, 4, 6, 1, 1) + self.label_24 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_24.setObjectName("label_24") + self.gridLayout_4.addWidget(self.label_24, 1, 0, 1, 1) + self.label_34 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_34.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_34.setObjectName("label_34") + self.gridLayout_4.addWidget(self.label_34, 4, 5, 1, 1) + self.generation_pulse_freq_edit = QtWidgets.QLineEdit(parent=self.generation_tab) + self.generation_pulse_freq_edit.setMaximumSize(QtCore.QSize(400, 16777215)) + self.generation_pulse_freq_edit.setObjectName("generation_pulse_freq_edit") + self.gridLayout_4.addWidget(self.generation_pulse_freq_edit, 1, 1, 1, 2) + self.label_28 = QtWidgets.QLabel(parent=self.generation_tab) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(22) + self.label_28.setFont(font) + self.label_28.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignLeading|QtCore.Qt.AlignmentFlag.AlignLeft) + self.label_28.setObjectName("label_28") + self.gridLayout_4.addWidget(self.label_28, 3, 0, 1, 7) + self.label_25 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_25.setObjectName("label_25") + self.gridLayout_4.addWidget(self.label_25, 1, 3, 1, 1) + self.label_29 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_29.setAlignment(QtCore.Qt.AlignmentFlag.AlignBottom|QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_29.setObjectName("label_29") + self.gridLayout_4.addWidget(self.label_29, 4, 0, 1, 1) + self.generation_head_hours_lbl = QtWidgets.QLabel(parent=self.generation_tab) + self.generation_head_hours_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.generation_head_hours_lbl.setObjectName("generation_head_hours_lbl") + self.gridLayout_4.addWidget(self.generation_head_hours_lbl, 5, 0, 1, 1) + self.generation_interlock_status_lbl = QtWidgets.QLabel(parent=self.generation_tab) + self.generation_interlock_status_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.generation_interlock_status_lbl.setObjectName("generation_interlock_status_lbl") + self.gridLayout_4.addWidget(self.generation_interlock_status_lbl, 5, 1, 1, 1) + self.generation_enable_status_lbl = QtWidgets.QLabel(parent=self.generation_tab) + self.generation_enable_status_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.generation_enable_status_lbl.setObjectName("generation_enable_status_lbl") + self.gridLayout_4.addWidget(self.generation_enable_status_lbl, 5, 2, 1, 1) + self.generation_warmup_lbl = QtWidgets.QLabel(parent=self.generation_tab) + self.generation_warmup_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.generation_warmup_lbl.setObjectName("generation_warmup_lbl") + self.gridLayout_4.addWidget(self.generation_warmup_lbl, 5, 3, 1, 1) + self.generation_emission_lbl = QtWidgets.QLabel(parent=self.generation_tab) + self.generation_emission_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.generation_emission_lbl.setObjectName("generation_emission_lbl") + self.gridLayout_4.addWidget(self.generation_emission_lbl, 5, 4, 1, 1) + self.generation_headtemp_lbl = QtWidgets.QLabel(parent=self.generation_tab) + self.generation_headtemp_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.generation_headtemp_lbl.setObjectName("generation_headtemp_lbl") + self.gridLayout_4.addWidget(self.generation_headtemp_lbl, 5, 5, 1, 1) + self.generation_diodetemp_lbl = QtWidgets.QLabel(parent=self.generation_tab) + self.generation_diodetemp_lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter|QtCore.Qt.AlignmentFlag.AlignTop) + self.generation_diodetemp_lbl.setObjectName("generation_diodetemp_lbl") + self.gridLayout_4.addWidget(self.generation_diodetemp_lbl, 5, 6, 1, 1) + self.label_36 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_36.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_36.setObjectName("label_36") + self.gridLayout_4.addWidget(self.label_36, 1, 4, 1, 1) + self.label_37 = QtWidgets.QLabel(parent=self.generation_tab) + self.label_37.setObjectName("label_37") + self.gridLayout_4.addWidget(self.label_37, 1, 5, 1, 1) + self.label_38 = QtWidgets.QLabel(parent=self.generation_tab) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setItalic(True) + font.setUnderline(True) + self.label_38.setFont(font) + self.label_38.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.label_38.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.label_38.setObjectName("label_38") + self.gridLayout_4.addWidget(self.label_38, 2, 4, 1, 2) + self.verticalLayout_7.addLayout(self.gridLayout_4) + self.configuration_tabwidget.addTab(self.generation_tab, "") + self.pulsedecimator_tab = QtWidgets.QWidget() + self.pulsedecimator_tab.setObjectName("pulsedecimator_tab") + self.verticalLayout_8 = QtWidgets.QVBoxLayout(self.pulsedecimator_tab) + self.verticalLayout_8.setObjectName("verticalLayout_8") + self.gridLayout_5 = QtWidgets.QGridLayout() + self.gridLayout_5.setObjectName("gridLayout_5") + self.label_49 = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.label_49.setObjectName("label_49") + self.gridLayout_5.addWidget(self.label_49, 6, 3, 1, 1) + self.label_42 = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.label_42.setObjectName("label_42") + self.gridLayout_5.addWidget(self.label_42, 4, 1, 1, 1) + spacerItem7 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_5.addItem(spacerItem7, 3, 0, 1, 1) + self.fpga_divider_value = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.fpga_divider_value.setObjectName("fpga_divider_value") + self.gridLayout_5.addWidget(self.fpga_divider_value, 5, 1, 1, 1) + self.fpga_fw_version = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.fpga_fw_version.setObjectName("fpga_fw_version") + self.gridLayout_5.addWidget(self.fpga_fw_version, 5, 0, 1, 1) + self.fpga_serial_port = QtWidgets.QComboBox(parent=self.pulsedecimator_tab) + self.fpga_serial_port.setObjectName("fpga_serial_port") + self.gridLayout_5.addWidget(self.fpga_serial_port, 0, 1, 1, 2) + self.fpga_divider_value_edit = QtWidgets.QLineEdit(parent=self.pulsedecimator_tab) + self.fpga_divider_value_edit.setMaximumSize(QtCore.QSize(150, 16777215)) + self.fpga_divider_value_edit.setObjectName("fpga_divider_value_edit") + self.gridLayout_5.addWidget(self.fpga_divider_value_edit, 1, 1, 1, 1) + self.fpga_refresh_ports_btn = QtWidgets.QPushButton(parent=self.pulsedecimator_tab) + self.fpga_refresh_ports_btn.setObjectName("fpga_refresh_ports_btn") + self.gridLayout_5.addWidget(self.fpga_refresh_ports_btn, 0, 3, 1, 1) + self.fpga_supports_rowpack = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.fpga_supports_rowpack.setObjectName("fpga_supports_rowpack") + self.gridLayout_5.addWidget(self.fpga_supports_rowpack, 5, 2, 1, 1) + self.label_40 = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.label_40.setObjectName("label_40") + self.gridLayout_5.addWidget(self.label_40, 1, 0, 1, 1) + self.label_41 = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.label_41.setObjectName("label_41") + self.gridLayout_5.addWidget(self.label_41, 4, 0, 1, 1) + self.label_39 = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.label_39.setObjectName("label_39") + self.gridLayout_5.addWidget(self.label_39, 0, 0, 1, 1) + self.checkBox = QtWidgets.QCheckBox(parent=self.pulsedecimator_tab) + self.checkBox.setObjectName("checkBox") + self.gridLayout_5.addWidget(self.checkBox, 2, 0, 1, 2) + self.label_43 = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.label_43.setObjectName("label_43") + self.gridLayout_5.addWidget(self.label_43, 4, 2, 1, 1) + self.label_47 = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.label_47.setObjectName("label_47") + self.gridLayout_5.addWidget(self.label_47, 4, 3, 1, 1) + spacerItem8 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_5.addItem(spacerItem8, 7, 0, 1, 1) + self.fpga_connect_button = QtWidgets.QPushButton(parent=self.pulsedecimator_tab) + self.fpga_connect_button.setObjectName("fpga_connect_button") + self.gridLayout_5.addWidget(self.fpga_connect_button, 0, 4, 1, 1) + self.fpga_pixel_size = QtWidgets.QLabel(parent=self.pulsedecimator_tab) + self.fpga_pixel_size.setObjectName("fpga_pixel_size") + self.gridLayout_5.addWidget(self.fpga_pixel_size, 5, 3, 1, 1) + self.verticalLayout_8.addLayout(self.gridLayout_5) + self.configuration_tabwidget.addTab(self.pulsedecimator_tab, "") + self.t3rsl_tab = QtWidgets.QWidget() + self.t3rsl_tab.setObjectName("t3rsl_tab") + self.verticalLayout_9 = QtWidgets.QVBoxLayout(self.t3rsl_tab) + self.verticalLayout_9.setObjectName("verticalLayout_9") + self.gridLayout_6 = QtWidgets.QGridLayout() + self.gridLayout_6.setObjectName("gridLayout_6") + self.label_60 = QtWidgets.QLabel(parent=self.t3rsl_tab) + self.label_60.setObjectName("label_60") + self.gridLayout_6.addWidget(self.label_60, 1, 3, 1, 1) + self.t3r_connect_btn = QtWidgets.QPushButton(parent=self.t3rsl_tab) + self.t3r_connect_btn.setObjectName("t3r_connect_btn") + self.gridLayout_6.addWidget(self.t3r_connect_btn, 0, 4, 1, 1) + spacerItem9 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_6.addItem(spacerItem9, 0, 5, 1, 1) + self.lineEdit_2 = QtWidgets.QLineEdit(parent=self.t3rsl_tab) + self.lineEdit_2.setMaximumSize(QtCore.QSize(150, 16777215)) + self.lineEdit_2.setObjectName("lineEdit_2") + self.gridLayout_6.addWidget(self.lineEdit_2, 2, 1, 1, 1) + self.label_44 = QtWidgets.QLabel(parent=self.t3rsl_tab) + self.label_44.setObjectName("label_44") + self.gridLayout_6.addWidget(self.label_44, 0, 0, 1, 1) + self.comboBox_3 = QtWidgets.QComboBox(parent=self.t3rsl_tab) + self.comboBox_3.setMaximumSize(QtCore.QSize(200, 16777215)) + self.comboBox_3.setObjectName("comboBox_3") + self.gridLayout_6.addWidget(self.comboBox_3, 2, 4, 1, 1) + self.label_59 = QtWidgets.QLabel(parent=self.t3rsl_tab) + self.label_59.setObjectName("label_59") + self.gridLayout_6.addWidget(self.label_59, 2, 0, 1, 1) + self.t3r_refresh_ports_btn = QtWidgets.QPushButton(parent=self.t3rsl_tab) + self.t3r_refresh_ports_btn.setMaximumSize(QtCore.QSize(150, 16777215)) + self.t3r_refresh_ports_btn.setObjectName("t3r_refresh_ports_btn") + self.gridLayout_6.addWidget(self.t3r_refresh_ports_btn, 0, 3, 1, 1) + self.label_61 = QtWidgets.QLabel(parent=self.t3rsl_tab) + self.label_61.setObjectName("label_61") + self.gridLayout_6.addWidget(self.label_61, 2, 3, 1, 1) + self.label_58 = QtWidgets.QLabel(parent=self.t3rsl_tab) + self.label_58.setObjectName("label_58") + self.gridLayout_6.addWidget(self.label_58, 1, 0, 1, 1) + self.comboBox_2 = QtWidgets.QComboBox(parent=self.t3rsl_tab) + self.comboBox_2.setMaximumSize(QtCore.QSize(200, 16777215)) + self.comboBox_2.setObjectName("comboBox_2") + self.gridLayout_6.addWidget(self.comboBox_2, 1, 4, 1, 1) + self.lineEdit = QtWidgets.QLineEdit(parent=self.t3rsl_tab) + self.lineEdit.setMaximumSize(QtCore.QSize(150, 16777215)) + self.lineEdit.setObjectName("lineEdit") + self.gridLayout_6.addWidget(self.lineEdit, 1, 1, 1, 1) + self.label_62 = QtWidgets.QLabel(parent=self.t3rsl_tab) + self.label_62.setObjectName("label_62") + self.gridLayout_6.addWidget(self.label_62, 1, 2, 1, 1) + self.t3r_serial_port_edit = QtWidgets.QComboBox(parent=self.t3rsl_tab) + self.t3r_serial_port_edit.setMinimumSize(QtCore.QSize(350, 0)) + self.t3r_serial_port_edit.setObjectName("t3r_serial_port_edit") + self.gridLayout_6.addWidget(self.t3r_serial_port_edit, 0, 1, 1, 2) + self.label_63 = QtWidgets.QLabel(parent=self.t3rsl_tab) + self.label_63.setObjectName("label_63") + self.gridLayout_6.addWidget(self.label_63, 2, 2, 1, 1) + self.verticalLayout_9.addLayout(self.gridLayout_6) + self.configuration_tabwidget.addTab(self.t3rsl_tab, "") + self.oscilloscope_tab = QtWidgets.QWidget() + self.oscilloscope_tab.setObjectName("oscilloscope_tab") + self.verticalLayout_10 = QtWidgets.QVBoxLayout(self.oscilloscope_tab) + self.verticalLayout_10.setObjectName("verticalLayout_10") + self.gridLayout_7 = QtWidgets.QGridLayout() + self.gridLayout_7.setObjectName("gridLayout_7") + self.label_45 = QtWidgets.QLabel(parent=self.oscilloscope_tab) + self.label_45.setObjectName("label_45") + self.gridLayout_7.addWidget(self.label_45, 0, 0, 1, 1) + self.scope_ip_address_edit = QtWidgets.QLineEdit(parent=self.oscilloscope_tab) + self.scope_ip_address_edit.setObjectName("scope_ip_address_edit") + self.gridLayout_7.addWidget(self.scope_ip_address_edit, 0, 1, 1, 1) + self.scope_connect_btn = QtWidgets.QPushButton(parent=self.oscilloscope_tab) + self.scope_connect_btn.setObjectName("scope_connect_btn") + self.gridLayout_7.addWidget(self.scope_connect_btn, 0, 2, 1, 1) + self.verticalLayout_10.addLayout(self.gridLayout_7) + self.configuration_tabwidget.addTab(self.oscilloscope_tab, "") + self.verticalLayout_4.addWidget(self.configuration_tabwidget) + self.gridLayout.addLayout(self.verticalLayout_4, 0, 0, 1, 1) + self.horizontalLayout_4 = QtWidgets.QHBoxLayout() + self.horizontalLayout_4.setObjectName("horizontalLayout_4") + spacerItem10 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_4.addItem(spacerItem10) + self.options_save_settings_btn = QtWidgets.QPushButton(parent=self.options_page) + self.options_save_settings_btn.setObjectName("options_save_settings_btn") + self.horizontalLayout_4.addWidget(self.options_save_settings_btn) + self.options_cancel_btn = QtWidgets.QPushButton(parent=self.options_page) + self.options_cancel_btn.setObjectName("options_cancel_btn") + self.horizontalLayout_4.addWidget(self.options_cancel_btn) + self.gridLayout.addLayout(self.horizontalLayout_4, 1, 0, 1, 1) + self.verticalLayout_3.addLayout(self.gridLayout) + self.stackedWidget.addWidget(self.options_page) + self.newscan_page_1 = QtWidgets.QWidget() + self.newscan_page_1.setObjectName("newscan_page_1") + self.verticalLayout_12 = QtWidgets.QVBoxLayout(self.newscan_page_1) + self.verticalLayout_12.setObjectName("verticalLayout_12") + self.verticalLayout_11 = QtWidgets.QVBoxLayout() + self.verticalLayout_11.setObjectName("verticalLayout_11") + self.label_46 = QtWidgets.QLabel(parent=self.newscan_page_1) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(32) + self.label_46.setFont(font) + self.label_46.setObjectName("label_46") + self.verticalLayout_11.addWidget(self.label_46) + self.gridLayout_8 = QtWidgets.QGridLayout() + self.gridLayout_8.setObjectName("gridLayout_8") + self.label_69 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_69.setObjectName("label_69") + self.gridLayout_8.addWidget(self.label_69, 13, 2, 1, 1) + self.verticalLayout_17 = QtWidgets.QVBoxLayout() + self.verticalLayout_17.setObjectName("verticalLayout_17") + spacerItem11 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_17.addItem(spacerItem11) + self.label_72 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_72.setObjectName("label_72") + self.verticalLayout_17.addWidget(self.label_72) + self.newscan_current_x_position_label = QtWidgets.QLabel(parent=self.newscan_page_1) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(28) + self.newscan_current_x_position_label.setFont(font) + self.newscan_current_x_position_label.setObjectName("newscan_current_x_position_label") + self.verticalLayout_17.addWidget(self.newscan_current_x_position_label) + self.label_74 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_74.setObjectName("label_74") + self.verticalLayout_17.addWidget(self.label_74) + self.newscan_current_y_position_label = QtWidgets.QLabel(parent=self.newscan_page_1) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(28) + self.newscan_current_y_position_label.setFont(font) + self.newscan_current_y_position_label.setObjectName("newscan_current_y_position_label") + self.verticalLayout_17.addWidget(self.newscan_current_y_position_label) + spacerItem12 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_17.addItem(spacerItem12) + self.newscan_set_current_as_start_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_set_current_as_start_btn.setObjectName("newscan_set_current_as_start_btn") + self.verticalLayout_17.addWidget(self.newscan_set_current_as_start_btn) + self.newscan_get_delta_from_current_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_get_delta_from_current_btn.setObjectName("newscan_get_delta_from_current_btn") + self.verticalLayout_17.addWidget(self.newscan_get_delta_from_current_btn) + spacerItem13 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_17.addItem(spacerItem13) + self.gridLayout_8.addLayout(self.verticalLayout_17, 14, 1, 1, 1) + self.label_48 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_48.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_48.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_48.setObjectName("label_48") + self.gridLayout_8.addWidget(self.label_48, 0, 0, 1, 1) + spacerItem14 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_8.addItem(spacerItem14, 14, 3, 1, 1) + spacerItem15 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_8.addItem(spacerItem15, 10, 5, 1, 1) + self.newscan_50_micron_radio = QtWidgets.QRadioButton(parent=self.newscan_page_1) + self.newscan_50_micron_radio.setObjectName("newscan_50_micron_radio") + self.gridLayout_8.addWidget(self.newscan_50_micron_radio, 7, 1, 1, 1) + self.label_56 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_56.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_56.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_56.setObjectName("label_56") + self.gridLayout_8.addWidget(self.label_56, 9, 0, 1, 1) + self.label_66 = QtWidgets.QLabel(parent=self.newscan_page_1) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(24) + self.label_66.setFont(font) + self.label_66.setObjectName("label_66") + self.gridLayout_8.addWidget(self.label_66, 11, 0, 1, 6) + self.newscan_camera_preview = QtWidgets.QWidget(parent=self.newscan_page_1) + self.newscan_camera_preview.setMinimumSize(QtCore.QSize(480, 480)) + self.newscan_camera_preview.setMaximumSize(QtCore.QSize(480, 480)) + self.newscan_camera_preview.setStyleSheet("QWidget {\n" +" border: 1px solid #FFF\n" +"}") + self.newscan_camera_preview.setObjectName("newscan_camera_preview") + self.gridLayout_8.addWidget(self.newscan_camera_preview, 14, 5, 1, 1) + spacerItem16 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_8.addItem(spacerItem16, 1, 5, 1, 1) + self.label_51 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_51.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_51.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_51.setObjectName("label_51") + self.gridLayout_8.addWidget(self.label_51, 3, 0, 1, 1) + self.label_57 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_57.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_57.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_57.setObjectName("label_57") + self.gridLayout_8.addWidget(self.label_57, 7, 0, 1, 1) + self.label_54 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_54.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_54.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_54.setObjectName("label_54") + self.gridLayout_8.addWidget(self.label_54, 3, 2, 1, 1) + self.label_53 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_53.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_53.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_53.setObjectName("label_53") + self.gridLayout_8.addWidget(self.label_53, 1, 2, 1, 1) + self.verticalLayout_16 = QtWidgets.QVBoxLayout() + self.verticalLayout_16.setObjectName("verticalLayout_16") + self.newscan_toggle_vis_laser_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_toggle_vis_laser_btn.setMaximumSize(QtCore.QSize(150, 16777215)) + self.newscan_toggle_vis_laser_btn.setObjectName("newscan_toggle_vis_laser_btn") + self.verticalLayout_16.addWidget(self.newscan_toggle_vis_laser_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter) + self.label_70 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_70.setMaximumSize(QtCore.QSize(16777215, 16777215)) + self.label_70.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.label_70.setObjectName("label_70") + self.verticalLayout_16.addWidget(self.label_70) + self.newscan_vis_laser_power_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newscan_vis_laser_power_edit.setMaximumSize(QtCore.QSize(50, 16777215)) + self.newscan_vis_laser_power_edit.setObjectName("newscan_vis_laser_power_edit") + self.verticalLayout_16.addWidget(self.newscan_vis_laser_power_edit, 0, QtCore.Qt.AlignmentFlag.AlignHCenter) + spacerItem17 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_16.addItem(spacerItem17) + self.gridLayout_8.addLayout(self.verticalLayout_16, 14, 2, 1, 1) + self.newscan_friendly_name_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newscan_friendly_name_edit.setObjectName("newscan_friendly_name_edit") + self.gridLayout_8.addWidget(self.newscan_friendly_name_edit, 0, 1, 1, 4) + self.newscan_num_angles_combo = QtWidgets.QComboBox(parent=self.newscan_page_1) + self.newscan_num_angles_combo.setMaximumSize(QtCore.QSize(100, 16777215)) + self.newscan_num_angles_combo.setObjectName("newscan_num_angles_combo") + self.gridLayout_8.addWidget(self.newscan_num_angles_combo, 4, 1, 1, 4) + self.newscan_100_micron_radio = QtWidgets.QRadioButton(parent=self.newscan_page_1) + self.newscan_100_micron_radio.setObjectName("newscan_100_micron_radio") + self.gridLayout_8.addWidget(self.newscan_100_micron_radio, 7, 2, 1, 1) + self.newscan_start_x_coord_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newscan_start_x_coord_edit.setMaximumSize(QtCore.QSize(100, 16777215)) + self.newscan_start_x_coord_edit.setObjectName("newscan_start_x_coord_edit") + self.gridLayout_8.addWidget(self.newscan_start_x_coord_edit, 1, 1, 1, 1) + self.label_67 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_67.setObjectName("label_67") + self.gridLayout_8.addWidget(self.label_67, 13, 0, 1, 1) + self.newscan_delta_x_coord_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newscan_delta_x_coord_edit.setMaximumSize(QtCore.QSize(100, 16777215)) + self.newscan_delta_x_coord_edit.setObjectName("newscan_delta_x_coord_edit") + self.gridLayout_8.addWidget(self.newscan_delta_x_coord_edit, 3, 1, 1, 1) + self.label_76 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_76.setMaximumSize(QtCore.QSize(480, 16777215)) + self.label_76.setObjectName("label_76") + self.gridLayout_8.addWidget(self.label_76, 13, 4, 1, 1) + self.label_50 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_50.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_50.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_50.setObjectName("label_50") + self.gridLayout_8.addWidget(self.label_50, 1, 0, 1, 1) + self.label_52 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_52.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_52.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_52.setObjectName("label_52") + self.gridLayout_8.addWidget(self.label_52, 4, 0, 1, 1) + self.newcsan_file_prefix_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newcsan_file_prefix_edit.setObjectName("newcsan_file_prefix_edit") + self.gridLayout_8.addWidget(self.newcsan_file_prefix_edit, 8, 1, 1, 4) + self.newscan_browse_folders_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_browse_folders_btn.setMaximumSize(QtCore.QSize(150, 16777215)) + self.newscan_browse_folders_btn.setObjectName("newscan_browse_folders_btn") + self.gridLayout_8.addWidget(self.newscan_browse_folders_btn, 9, 5, 1, 1) + self.verticalLayout_18 = QtWidgets.QVBoxLayout() + self.verticalLayout_18.setObjectName("verticalLayout_18") + spacerItem18 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_18.addItem(spacerItem18) + self.label_77 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_77.setObjectName("label_77") + self.verticalLayout_18.addWidget(self.label_77) + self.newscan_ccd_exposure_hslide = QtWidgets.QSlider(parent=self.newscan_page_1) + self.newscan_ccd_exposure_hslide.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.newscan_ccd_exposure_hslide.setObjectName("newscan_ccd_exposure_hslide") + self.verticalLayout_18.addWidget(self.newscan_ccd_exposure_hslide) + self.label_78 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_78.setObjectName("label_78") + self.verticalLayout_18.addWidget(self.label_78) + self.newscan_ccd_ampgain_hslide = QtWidgets.QSlider(parent=self.newscan_page_1) + self.newscan_ccd_ampgain_hslide.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.newscan_ccd_ampgain_hslide.setObjectName("newscan_ccd_ampgain_hslide") + self.verticalLayout_18.addWidget(self.newscan_ccd_ampgain_hslide) + spacerItem19 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_18.addItem(spacerItem19) + self.gridLayout_8.addLayout(self.verticalLayout_18, 14, 4, 1, 1) + self.verticalLayout_14 = QtWidgets.QVBoxLayout() + self.verticalLayout_14.setObjectName("verticalLayout_14") + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.newscan_jog_x_pos_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_jog_x_pos_btn.setObjectName("newscan_jog_x_pos_btn") + self.horizontalLayout.addWidget(self.newscan_jog_x_pos_btn) + self.verticalLayout_15 = QtWidgets.QVBoxLayout() + self.verticalLayout_15.setObjectName("verticalLayout_15") + spacerItem20 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_15.addItem(spacerItem20) + self.newscan_jog_y_pos_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_jog_y_pos_btn.setObjectName("newscan_jog_y_pos_btn") + self.verticalLayout_15.addWidget(self.newscan_jog_y_pos_btn) + self.label_68 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_68.setObjectName("label_68") + self.verticalLayout_15.addWidget(self.label_68) + self.newscan_jog_speed_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newscan_jog_speed_edit.setMaximumSize(QtCore.QSize(75, 16777215)) + self.newscan_jog_speed_edit.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.newscan_jog_speed_edit.setObjectName("newscan_jog_speed_edit") + self.verticalLayout_15.addWidget(self.newscan_jog_speed_edit, 0, QtCore.Qt.AlignmentFlag.AlignHCenter) + self.newscan_jog_y_neg_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_jog_y_neg_btn.setObjectName("newscan_jog_y_neg_btn") + self.verticalLayout_15.addWidget(self.newscan_jog_y_neg_btn) + spacerItem21 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_15.addItem(spacerItem21) + self.horizontalLayout.addLayout(self.verticalLayout_15) + self.newscan_jog_x_neg_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_jog_x_neg_btn.setObjectName("newscan_jog_x_neg_btn") + self.horizontalLayout.addWidget(self.newscan_jog_x_neg_btn) + self.verticalLayout_14.addLayout(self.horizontalLayout) + self.gridLayout_8.addLayout(self.verticalLayout_14, 14, 0, 1, 1) + self.label_71 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_71.setMaximumSize(QtCore.QSize(480, 16777215)) + self.label_71.setObjectName("label_71") + self.gridLayout_8.addWidget(self.label_71, 13, 5, 1, 1) + self.label_55 = QtWidgets.QLabel(parent=self.newscan_page_1) + self.label_55.setMaximumSize(QtCore.QSize(16777213, 16777215)) + self.label_55.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_55.setObjectName("label_55") + self.gridLayout_8.addWidget(self.label_55, 8, 0, 1, 1) + self.newscan_save_directory_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newscan_save_directory_edit.setMinimumSize(QtCore.QSize(400, 0)) + self.newscan_save_directory_edit.setObjectName("newscan_save_directory_edit") + self.gridLayout_8.addWidget(self.newscan_save_directory_edit, 9, 1, 1, 4) + self.newscan_start_y_coord_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newscan_start_y_coord_edit.setMaximumSize(QtCore.QSize(100, 16777215)) + self.newscan_start_y_coord_edit.setObjectName("newscan_start_y_coord_edit") + self.gridLayout_8.addWidget(self.newscan_start_y_coord_edit, 1, 3, 1, 1) + self.newscan_delta_y_coord_edit = QtWidgets.QLineEdit(parent=self.newscan_page_1) + self.newscan_delta_y_coord_edit.setMaximumSize(QtCore.QSize(100, 16777215)) + self.newscan_delta_y_coord_edit.setObjectName("newscan_delta_y_coord_edit") + self.gridLayout_8.addWidget(self.newscan_delta_y_coord_edit, 3, 3, 1, 1) + self.newscan_250_micron_radio = QtWidgets.QRadioButton(parent=self.newscan_page_1) + self.newscan_250_micron_radio.setObjectName("newscan_250_micron_radio") + self.gridLayout_8.addWidget(self.newscan_250_micron_radio, 7, 3, 1, 1) + self.verticalLayout_11.addLayout(self.gridLayout_8) + self.verticalLayout_12.addLayout(self.verticalLayout_11) + self.newscan_continue_to_next_btn = QtWidgets.QPushButton(parent=self.newscan_page_1) + self.newscan_continue_to_next_btn.setObjectName("newscan_continue_to_next_btn") + self.verticalLayout_12.addWidget(self.newscan_continue_to_next_btn) + self.stackedWidget.addWidget(self.newscan_page_1) + self.continuescan_page_1 = QtWidgets.QWidget() + self.continuescan_page_1.setObjectName("continuescan_page_1") + self.verticalLayout_13 = QtWidgets.QVBoxLayout(self.continuescan_page_1) + self.verticalLayout_13.setObjectName("verticalLayout_13") + self.gridLayout_9 = QtWidgets.QGridLayout() + self.gridLayout_9.setObjectName("gridLayout_9") + self.label_79 = QtWidgets.QLabel(parent=self.continuescan_page_1) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(20) + self.label_79.setFont(font) + self.label_79.setObjectName("label_79") + self.gridLayout_9.addWidget(self.label_79, 6, 0, 1, 4) + self.label_83 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_83.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_83.setObjectName("label_83") + self.gridLayout_9.addWidget(self.label_83, 9, 0, 1, 1) + self.label_85 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_85.setMinimumSize(QtCore.QSize(150, 0)) + self.label_85.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_85.setObjectName("label_85") + self.gridLayout_9.addWidget(self.label_85, 10, 2, 1, 1) + self.label_75 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_75.setObjectName("label_75") + self.gridLayout_9.addWidget(self.label_75, 3, 0, 1, 1) + self.continuescan_x_delta_label = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.continuescan_x_delta_label.setObjectName("continuescan_x_delta_label") + self.gridLayout_9.addWidget(self.continuescan_x_delta_label, 8, 3, 1, 1) + self.lineEdit_3 = QtWidgets.QLineEdit(parent=self.continuescan_page_1) + self.lineEdit_3.setObjectName("lineEdit_3") + self.gridLayout_9.addWidget(self.lineEdit_3, 3, 1, 1, 3) + self.continuescan_x_start_label = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.continuescan_x_start_label.setObjectName("continuescan_x_start_label") + self.gridLayout_9.addWidget(self.continuescan_x_start_label, 8, 1, 1, 1) + self.continuescan_num_angles_label = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.continuescan_num_angles_label.setObjectName("continuescan_num_angles_label") + self.gridLayout_9.addWidget(self.continuescan_num_angles_label, 10, 1, 1, 1) + self.continuescan_pixel_size_label = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.continuescan_pixel_size_label.setObjectName("continuescan_pixel_size_label") + self.gridLayout_9.addWidget(self.continuescan_pixel_size_label, 10, 3, 1, 1) + self.label_84 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_84.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_84.setObjectName("label_84") + self.gridLayout_9.addWidget(self.label_84, 10, 0, 1, 1) + self.label_80 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_80.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_80.setObjectName("label_80") + self.gridLayout_9.addWidget(self.label_80, 7, 0, 1, 1) + self.pushButton = QtWidgets.QPushButton(parent=self.continuescan_page_1) + self.pushButton.setMaximumSize(QtCore.QSize(150, 16777215)) + self.pushButton.setObjectName("pushButton") + self.gridLayout_9.addWidget(self.pushButton, 3, 4, 1, 1) + spacerItem22 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_9.addItem(spacerItem22, 12, 1, 1, 1) + self.label_73 = QtWidgets.QLabel(parent=self.continuescan_page_1) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(32) + self.label_73.setFont(font) + self.label_73.setObjectName("label_73") + self.gridLayout_9.addWidget(self.label_73, 1, 0, 1, 6) + self.label_90 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_90.setMinimumSize(QtCore.QSize(150, 0)) + self.label_90.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_90.setObjectName("label_90") + self.gridLayout_9.addWidget(self.label_90, 9, 2, 1, 1) + self.continuescan_y_delta_label = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.continuescan_y_delta_label.setObjectName("continuescan_y_delta_label") + self.gridLayout_9.addWidget(self.continuescan_y_delta_label, 9, 3, 1, 1) + self.continuescan_last_complete_scan = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.continuescan_last_complete_scan.setObjectName("continuescan_last_complete_scan") + self.gridLayout_9.addWidget(self.continuescan_last_complete_scan, 11, 1, 1, 1) + self.continuescan_y_start_label = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.continuescan_y_start_label.setObjectName("continuescan_y_start_label") + self.gridLayout_9.addWidget(self.continuescan_y_start_label, 9, 1, 1, 1) + self.label_86 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_86.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_86.setObjectName("label_86") + self.gridLayout_9.addWidget(self.label_86, 11, 0, 1, 1) + self.continuescan_friendlyname = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.continuescan_friendlyname.setObjectName("continuescan_friendlyname") + self.gridLayout_9.addWidget(self.continuescan_friendlyname, 7, 1, 1, 3) + self.label_87 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_87.setMinimumSize(QtCore.QSize(150, 0)) + self.label_87.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_87.setObjectName("label_87") + self.gridLayout_9.addWidget(self.label_87, 8, 2, 1, 1) + self.label_82 = QtWidgets.QLabel(parent=self.continuescan_page_1) + self.label_82.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight|QtCore.Qt.AlignmentFlag.AlignTrailing|QtCore.Qt.AlignmentFlag.AlignVCenter) + self.label_82.setObjectName("label_82") + self.gridLayout_9.addWidget(self.label_82, 8, 0, 1, 1) + spacerItem23 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_9.addItem(spacerItem23, 5, 0, 1, 1) + self.pushButton_2 = QtWidgets.QPushButton(parent=self.continuescan_page_1) + self.pushButton_2.setObjectName("pushButton_2") + self.gridLayout_9.addWidget(self.pushButton_2, 3, 5, 1, 1) + spacerItem24 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_9.addItem(spacerItem24, 8, 4, 1, 2) + spacerItem25 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_9.addItem(spacerItem25, 3, 6, 1, 1) + self.verticalLayout_13.addLayout(self.gridLayout_9) + self.continuescan_resume_scans = QtWidgets.QPushButton(parent=self.continuescan_page_1) + self.continuescan_resume_scans.setObjectName("continuescan_resume_scans") + self.verticalLayout_13.addWidget(self.continuescan_resume_scans) + self.stackedWidget.addWidget(self.continuescan_page_1) + self.scan_progress_page = QtWidgets.QWidget() + self.scan_progress_page.setObjectName("scan_progress_page") + self.verticalLayout_19 = QtWidgets.QVBoxLayout(self.scan_progress_page) + self.verticalLayout_19.setObjectName("verticalLayout_19") + self.gridLayout_11 = QtWidgets.QGridLayout() + self.gridLayout_11.setObjectName("gridLayout_11") + self.scanning_scan_progbar = QtWidgets.QProgressBar(parent=self.scan_progress_page) + self.scanning_scan_progbar.setProperty("value", 24) + self.scanning_scan_progbar.setObjectName("scanning_scan_progbar") + self.gridLayout_11.addWidget(self.scanning_scan_progbar, 7, 0, 1, 1) + self.verticalLayout_20 = QtWidgets.QVBoxLayout() + self.verticalLayout_20.setObjectName("verticalLayout_20") + self.label_102 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_102.setObjectName("label_102") + self.verticalLayout_20.addWidget(self.label_102) + self.horizontalLayout_3 = QtWidgets.QHBoxLayout() + self.horizontalLayout_3.setObjectName("horizontalLayout_3") + self.label_105 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_105.setObjectName("label_105") + self.horizontalLayout_3.addWidget(self.label_105) + self.scanning_x_position_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_x_position_label.setObjectName("scanning_x_position_label") + self.horizontalLayout_3.addWidget(self.scanning_x_position_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_3) + self.horizontalLayout_5 = QtWidgets.QHBoxLayout() + self.horizontalLayout_5.setObjectName("horizontalLayout_5") + self.label_109 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_109.setObjectName("label_109") + self.horizontalLayout_5.addWidget(self.label_109) + self.scanning_y_position_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_y_position_label.setObjectName("scanning_y_position_label") + self.horizontalLayout_5.addWidget(self.scanning_y_position_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_5) + self.horizontalLayout_2 = QtWidgets.QHBoxLayout() + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.label_103 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_103.setObjectName("label_103") + self.horizontalLayout_2.addWidget(self.label_103) + self.scanning_stage_state_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_stage_state_label.setObjectName("scanning_stage_state_label") + self.horizontalLayout_2.addWidget(self.scanning_stage_state_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_2) + spacerItem26 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_20.addItem(spacerItem26) + self.label_111 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_111.setObjectName("label_111") + self.verticalLayout_20.addWidget(self.label_111) + self.horizontalLayout_8 = QtWidgets.QHBoxLayout() + self.horizontalLayout_8.setObjectName("horizontalLayout_8") + self.label_116 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_116.setObjectName("label_116") + self.horizontalLayout_8.addWidget(self.label_116) + self.scanning_vis_status_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_vis_status_label.setObjectName("scanning_vis_status_label") + self.horizontalLayout_8.addWidget(self.scanning_vis_status_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_8) + self.horizontalLayout_9 = QtWidgets.QHBoxLayout() + self.horizontalLayout_9.setObjectName("horizontalLayout_9") + self.label_118 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_118.setObjectName("label_118") + self.horizontalLayout_9.addWidget(self.label_118) + self.scanning_vis_power_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_vis_power_label.setObjectName("scanning_vis_power_label") + self.horizontalLayout_9.addWidget(self.scanning_vis_power_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_9) + self.horizontalLayout_10 = QtWidgets.QHBoxLayout() + self.horizontalLayout_10.setObjectName("horizontalLayout_10") + self.label_120 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_120.setObjectName("label_120") + self.horizontalLayout_10.addWidget(self.label_120) + self.scanning_vis_temperature_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_vis_temperature_label.setObjectName("scanning_vis_temperature_label") + self.horizontalLayout_10.addWidget(self.scanning_vis_temperature_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_10) + self.horizontalLayout_6 = QtWidgets.QHBoxLayout() + self.horizontalLayout_6.setObjectName("horizontalLayout_6") + self.label_112 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_112.setObjectName("label_112") + self.horizontalLayout_6.addWidget(self.label_112) + self.scanning_vis_error_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_vis_error_label.setObjectName("scanning_vis_error_label") + self.horizontalLayout_6.addWidget(self.scanning_vis_error_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_6) + spacerItem27 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_20.addItem(spacerItem27) + self.label_122 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_122.setObjectName("label_122") + self.verticalLayout_20.addWidget(self.label_122) + self.horizontalLayout_12 = QtWidgets.QHBoxLayout() + self.horizontalLayout_12.setObjectName("horizontalLayout_12") + self.label_125 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_125.setObjectName("label_125") + self.horizontalLayout_12.addWidget(self.label_125) + self.scanning_ir_status_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_ir_status_label.setObjectName("scanning_ir_status_label") + self.horizontalLayout_12.addWidget(self.scanning_ir_status_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_12) + self.horizontalLayout_15 = QtWidgets.QHBoxLayout() + self.horizontalLayout_15.setObjectName("horizontalLayout_15") + self.label_131 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_131.setObjectName("label_131") + self.horizontalLayout_15.addWidget(self.label_131) + self.scanning_ir_freq_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_ir_freq_label.setObjectName("scanning_ir_freq_label") + self.horizontalLayout_15.addWidget(self.scanning_ir_freq_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_15) + self.horizontalLayout_16 = QtWidgets.QHBoxLayout() + self.horizontalLayout_16.setObjectName("horizontalLayout_16") + self.label_133 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_133.setObjectName("label_133") + self.horizontalLayout_16.addWidget(self.label_133) + self.scanning_ir_current_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_ir_current_label.setObjectName("scanning_ir_current_label") + self.horizontalLayout_16.addWidget(self.scanning_ir_current_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_16) + self.horizontalLayout_17 = QtWidgets.QHBoxLayout() + self.horizontalLayout_17.setObjectName("horizontalLayout_17") + self.label_135 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_135.setObjectName("label_135") + self.horizontalLayout_17.addWidget(self.label_135) + self.scanning_ir_headhours_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_ir_headhours_label.setObjectName("scanning_ir_headhours_label") + self.horizontalLayout_17.addWidget(self.scanning_ir_headhours_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_17) + self.horizontalLayout_11 = QtWidgets.QHBoxLayout() + self.horizontalLayout_11.setObjectName("horizontalLayout_11") + self.label_123 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_123.setObjectName("label_123") + self.horizontalLayout_11.addWidget(self.label_123) + self.scanning_ir_temp_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_ir_temp_label.setObjectName("scanning_ir_temp_label") + self.horizontalLayout_11.addWidget(self.scanning_ir_temp_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_11) + self.horizontalLayout_20 = QtWidgets.QHBoxLayout() + self.horizontalLayout_20.setObjectName("horizontalLayout_20") + self.label_142 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_142.setObjectName("label_142") + self.horizontalLayout_20.addWidget(self.label_142) + self.scanning_ir_error_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_ir_error_label.setObjectName("scanning_ir_error_label") + self.horizontalLayout_20.addWidget(self.scanning_ir_error_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_20) + spacerItem28 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout_20.addItem(spacerItem28) + self.label_137 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_137.setObjectName("label_137") + self.verticalLayout_20.addWidget(self.label_137) + self.horizontalLayout_19 = QtWidgets.QHBoxLayout() + self.horizontalLayout_19.setObjectName("horizontalLayout_19") + self.label_140 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_140.setObjectName("label_140") + self.horizontalLayout_19.addWidget(self.label_140) + self.scanning_pulsedivider_status_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_pulsedivider_status_label.setObjectName("scanning_pulsedivider_status_label") + self.horizontalLayout_19.addWidget(self.scanning_pulsedivider_status_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_19) + self.horizontalLayout_18 = QtWidgets.QHBoxLayout() + self.horizontalLayout_18.setObjectName("horizontalLayout_18") + self.label_138 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_138.setObjectName("label_138") + self.horizontalLayout_18.addWidget(self.label_138) + self.scanning_pulsedivider_divider_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_pulsedivider_divider_label.setObjectName("scanning_pulsedivider_divider_label") + self.horizontalLayout_18.addWidget(self.scanning_pulsedivider_divider_label) + self.verticalLayout_20.addLayout(self.horizontalLayout_18) + self.gridLayout_11.addLayout(self.verticalLayout_20, 1, 1, 10, 1) + spacerItem29 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.gridLayout_11.addItem(spacerItem29, 11, 1, 1, 1) + self.label_99 = QtWidgets.QLabel(parent=self.scan_progress_page) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(32) + self.label_99.setFont(font) + self.label_99.setObjectName("label_99") + self.gridLayout_11.addWidget(self.label_99, 0, 0, 1, 1) + self.label_146 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_146.setObjectName("label_146") + self.gridLayout_11.addWidget(self.label_146, 1, 0, 1, 1) + self.scanning_estimated_remaining_time_label = QtWidgets.QLabel(parent=self.scan_progress_page) + self.scanning_estimated_remaining_time_label.setObjectName("scanning_estimated_remaining_time_label") + self.gridLayout_11.addWidget(self.scanning_estimated_remaining_time_label, 9, 0, 1, 1) + self.label_101 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_101.setObjectName("label_101") + self.gridLayout_11.addWidget(self.label_101, 6, 0, 1, 1) + self.label_100 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_100.setObjectName("label_100") + self.gridLayout_11.addWidget(self.label_100, 3, 0, 1, 1) + self.label_144 = QtWidgets.QLabel(parent=self.scan_progress_page) + self.label_144.setObjectName("label_144") + self.gridLayout_11.addWidget(self.label_144, 8, 0, 1, 1) + self.scanning_overall_progbar = QtWidgets.QProgressBar(parent=self.scan_progress_page) + self.scanning_overall_progbar.setProperty("value", 24) + self.scanning_overall_progbar.setObjectName("scanning_overall_progbar") + self.gridLayout_11.addWidget(self.scanning_overall_progbar, 5, 0, 1, 1) + self.scanning_dc_preview_widget = QtWidgets.QWidget(parent=self.scan_progress_page) + self.scanning_dc_preview_widget.setMinimumSize(QtCore.QSize(640, 640)) + self.scanning_dc_preview_widget.setMaximumSize(QtCore.QSize(640, 640)) + self.scanning_dc_preview_widget.setStyleSheet("QWidget {\n" +" border: 1px solid #FFF;\n" +"}") + self.scanning_dc_preview_widget.setObjectName("scanning_dc_preview_widget") + self.gridLayout_11.addWidget(self.scanning_dc_preview_widget, 2, 0, 1, 1, QtCore.Qt.AlignmentFlag.AlignHCenter) + self.verticalLayout_19.addLayout(self.gridLayout_11) + self.abort_scan_button = QtWidgets.QPushButton(parent=self.scan_progress_page) + font = QtGui.QFont() + font.setFamily("Space Grotesk") + font.setPointSize(14) + self.abort_scan_button.setFont(font) + self.abort_scan_button.setObjectName("abort_scan_button") + self.verticalLayout_19.addWidget(self.abort_scan_button) + self.stackedWidget.addWidget(self.scan_progress_page) + self.verticalLayout_2.addWidget(self.stackedWidget) + MainWindow.setCentralWidget(self.centralwidget) + self.statusbar = QtWidgets.QStatusBar(parent=MainWindow) + self.statusbar.setObjectName("statusbar") + MainWindow.setStatusBar(self.statusbar) + + self.retranslateUi(MainWindow) + self.stackedWidget.setCurrentIndex(1) + self.configuration_tabwidget.setCurrentIndex(2) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + _translate = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) + self.label.setText(_translate("MainWindow", "Scanengine")) + self.label_2.setText(_translate("MainWindow", "What do you want to do?")) + self.start_new_scan_btn.setText(_translate("MainWindow", "Start a New Scan")) + self.resume_scan_btn.setText(_translate("MainWindow", "Resume an Interrupted Scan")) + self.edit_options_btn.setText(_translate("MainWindow", "Edit Options")) + self.label_3.setText(_translate("MainWindow", "Scanengine Configuration")) + self.label_65.setText(_translate("MainWindow", "Scan Kinematics:")) + self.label_15.setText(_translate("MainWindow", "Y\n" +"Coordinate:")) + self.label_14.setText(_translate("MainWindow", "X\n" +"Coordinate:")) + self.label_11.setText(_translate("MainWindow", "Trigger Behavior:")) + self.label_6.setText(_translate("MainWindow", "Scan Acceleration:")) + self.label_9.setText(_translate("MainWindow", "0 x 0 micron")) + self.label_4.setText(_translate("MainWindow", "Scan Velocity:")) + self.label_10.setText(_translate("MainWindow", "This is affected by settings in the\n" +"Generation/IR and PulseDecimator panels as well.")) + self.label_13.setText(_translate("MainWindow", "Optical Axis Correction")) + self.label_12.setText(_translate("MainWindow", "Serial Number:")) + self.label_7.setText(_translate("MainWindow", "mm/s2")) + self.stage_test_connection_btn.setText(_translate("MainWindow", "Test Connection")) + self.label_8.setText(_translate("MainWindow", "Effective Pixel\n" +"Size:")) + self.label_5.setText(_translate("MainWindow", "mm/s")) + self.label_64.setText(_translate("MainWindow", "Triggering Settings")) + self.configuration_tabwidget.setTabText(self.configuration_tabwidget.indexOf(self.kinematics_tab), _translate("MainWindow", "Kinematics")) + self.label_17.setText(_translate("MainWindow", "Interlock Status:")) + self.detection_diode_label.setText(_translate("MainWindow", "Unknown")) + self.label_19.setText(_translate("MainWindow", "Diode Status:")) + self.detection_serial_num_label.setText(_translate("MainWindow", "123456789")) + self.detection_test_btn.setText(_translate("MainWindow", "Test Connection && Query Laser")) + self.label_18.setText(_translate("MainWindow", "Keyswitch Status:")) + self.label_21.setText(_translate("MainWindow", "Default\n" +"Power Level:")) + self.label_16.setText(_translate("MainWindow", "Serial Number:")) + self.label_22.setText(_translate("MainWindow", "milliwatts")) + self.detection_interlock_label.setText(_translate("MainWindow", "Unknown")) + self.detection_keyswitch_label.setText(_translate("MainWindow", "Unknown")) + self.detection_temperature_label.setText(_translate("MainWindow", "0.00C")) + self.label_20.setText(_translate("MainWindow", "Temperature:")) + self.configuration_tabwidget.setTabText(self.configuration_tabwidget.indexOf(self.detection_tab), _translate("MainWindow", "Detection / VIS")) + self.label_30.setText(_translate("MainWindow", "Interlock Status:")) + self.label_26.setText(_translate("MainWindow", "Diode Pump\n" +"Current:")) + self.label_23.setText(_translate("MainWindow", "Serial Port:")) + self.refresh_serial_ports_btn.setText(_translate("MainWindow", "Refresh Ports")) + self.label_27.setText(_translate("MainWindow", "milliamps")) + self.label_33.setText(_translate("MainWindow", "Diode Status:")) + self.label_31.setText(_translate("MainWindow", "Enable Switch Status:")) + self.label_32.setText(_translate("MainWindow", "Warmup Lockout:")) + self.generation_connect_button.setText(_translate("MainWindow", "Connect")) + self.label_35.setText(_translate("MainWindow", "Diode Temperature:")) + self.label_24.setText(_translate("MainWindow", "Pulse Frequency:")) + self.label_34.setText(_translate("MainWindow", "Head Temperature:")) + self.label_28.setText(_translate("MainWindow", "Status Information:")) + self.label_25.setText(_translate("MainWindow", "Hertz")) + self.label_29.setText(_translate("MainWindow", "Head Hours:")) + self.generation_head_hours_lbl.setText(_translate("MainWindow", "Unknown")) + self.generation_interlock_status_lbl.setText(_translate("MainWindow", "Unknown")) + self.generation_enable_status_lbl.setText(_translate("MainWindow", "Unknown")) + self.generation_warmup_lbl.setText(_translate("MainWindow", "Unknown")) + self.generation_emission_lbl.setText(_translate("MainWindow", "Unknown")) + self.generation_headtemp_lbl.setText(_translate("MainWindow", "Unknown")) + self.generation_diodetemp_lbl.setText(_translate("MainWindow", "Unknown")) + self.label_36.setText(_translate("MainWindow", "Pixel Size:")) + self.label_37.setText(_translate("MainWindow", "0 x 0 microns")) + self.label_38.setText(_translate("MainWindow", "This is also affected by settings in the Kinematics\n" +"and PulseDecimator tabs.")) + self.configuration_tabwidget.setTabText(self.configuration_tabwidget.indexOf(self.generation_tab), _translate("MainWindow", "Generation / IR")) + self.label_49.setText(_translate("MainWindow", "This is also affected by settings on the\n" +"Kinematics and Generation tabs.")) + self.label_42.setText(_translate("MainWindow", "Current Divider Value")) + self.fpga_divider_value.setText(_translate("MainWindow", "Unknown")) + self.fpga_fw_version.setText(_translate("MainWindow", "Unknown")) + self.fpga_refresh_ports_btn.setText(_translate("MainWindow", "Refresh Ports")) + self.fpga_supports_rowpack.setText(_translate("MainWindow", "Unknown")) + self.label_40.setText(_translate("MainWindow", "Divider Value:")) + self.label_41.setText(_translate("MainWindow", "Firmware Version")) + self.label_39.setText(_translate("MainWindow", "T3RSL\n" +"Communicaton Port:")) + self.checkBox.setText(_translate("MainWindow", "Enable RowPack feature?")) + self.label_43.setText(_translate("MainWindow", "Supports RowPack?")) + self.label_47.setText(_translate("MainWindow", "Effective Pixel Size:")) + self.fpga_connect_button.setText(_translate("MainWindow", "Connect && Query")) + self.fpga_pixel_size.setText(_translate("MainWindow", "0 x 0 microns")) + self.configuration_tabwidget.setTabText(self.configuration_tabwidget.indexOf(self.pulsedecimator_tab), _translate("MainWindow", "PulseDecimator")) + self.label_60.setText(_translate("MainWindow", "T Axis Microstepping Mode:")) + self.t3r_connect_btn.setText(_translate("MainWindow", "Connect && Query")) + self.label_44.setText(_translate("MainWindow", "T3RSL Communication Port:")) + self.label_59.setText(_translate("MainWindow", "GR Axis Drive Current")) + self.t3r_refresh_ports_btn.setText(_translate("MainWindow", "Refresh Ports")) + self.label_61.setText(_translate("MainWindow", "GR Axis Microstepping Mode:")) + self.label_58.setText(_translate("MainWindow", "T Axis Drive Current")) + self.label_62.setText(_translate("MainWindow", "milliamperes")) + self.label_63.setText(_translate("MainWindow", "milliamperes")) + self.configuration_tabwidget.setTabText(self.configuration_tabwidget.indexOf(self.t3rsl_tab), _translate("MainWindow", "T3R-SL")) + self.label_45.setText(_translate("MainWindow", "Oscilloscope IP Address:")) + self.scope_connect_btn.setText(_translate("MainWindow", "Test Connection")) + self.configuration_tabwidget.setTabText(self.configuration_tabwidget.indexOf(self.oscilloscope_tab), _translate("MainWindow", "Oscilloscope")) + self.options_save_settings_btn.setText(_translate("MainWindow", "Save Settings")) + self.options_cancel_btn.setText(_translate("MainWindow", "Cancel")) + self.label_46.setText(_translate("MainWindow", "Start a New Scan")) + self.label_69.setText(_translate("MainWindow", "VIS Laser:")) + self.label_72.setText(_translate("MainWindow", "X-Position")) + self.newscan_current_x_position_label.setText(_translate("MainWindow", "000.00")) + self.label_74.setText(_translate("MainWindow", "Y-Position")) + self.newscan_current_y_position_label.setText(_translate("MainWindow", "000.00")) + self.newscan_set_current_as_start_btn.setText(_translate("MainWindow", "Set as Start")) + self.newscan_get_delta_from_current_btn.setText(_translate("MainWindow", "Calculate Delta")) + self.label_48.setText(_translate("MainWindow", "Scan Friendly Name:")) + self.newscan_50_micron_radio.setText(_translate("MainWindow", "50 microns")) + self.label_56.setText(_translate("MainWindow", "Save to Folder:")) + self.label_66.setText(_translate("MainWindow", "Sample Check:")) + self.label_51.setText(_translate("MainWindow", "X Delta:")) + self.label_57.setText(_translate("MainWindow", "Row Size:")) + self.label_54.setText(_translate("MainWindow", "Y Delta:")) + self.label_53.setText(_translate("MainWindow", "Start Y\n" +"Coord:")) + self.newscan_toggle_vis_laser_btn.setText(_translate("MainWindow", "Toggle Emission")) + self.label_70.setText(_translate("MainWindow", "Test Power Level:")) + self.newscan_100_micron_radio.setText(_translate("MainWindow", "100 microns")) + self.label_67.setText(_translate("MainWindow", "Stage Jog Controls:")) + self.label_76.setText(_translate("MainWindow", "Camera Controls:")) + self.label_50.setText(_translate("MainWindow", "Start X\n" +"Coord:")) + self.label_52.setText(_translate("MainWindow", "Number of\n" +"Angles:")) + self.newscan_browse_folders_btn.setText(_translate("MainWindow", "Browse Folders")) + self.label_77.setText(_translate("MainWindow", "Exposure:")) + self.label_78.setText(_translate("MainWindow", "Gain:")) + self.newscan_jog_x_pos_btn.setText(_translate("MainWindow", "X - ")) + self.newscan_jog_y_pos_btn.setText(_translate("MainWindow", "Y +")) + self.label_68.setText(_translate("MainWindow", "Jog Speed [mm/s]:")) + self.newscan_jog_y_neg_btn.setText(_translate("MainWindow", "Y -")) + self.newscan_jog_x_neg_btn.setText(_translate("MainWindow", "X +")) + self.label_71.setText(_translate("MainWindow", "Camera Preview:")) + self.label_55.setText(_translate("MainWindow", "File Prefix:")) + self.newscan_250_micron_radio.setText(_translate("MainWindow", "250 microns")) + self.newscan_continue_to_next_btn.setText(_translate("MainWindow", "Continue")) + self.label_79.setText(_translate("MainWindow", "Metadata Summary:")) + self.label_83.setText(_translate("MainWindow", "Y-Start:")) + self.label_85.setText(_translate("MainWindow", "Pixel Size:")) + self.label_75.setText(_translate("MainWindow", "Select Existing Scan Directory:")) + self.continuescan_x_delta_label.setText(_translate("MainWindow", "Unknown")) + self.continuescan_x_start_label.setText(_translate("MainWindow", "Unknown")) + self.continuescan_num_angles_label.setText(_translate("MainWindow", "Unknown")) + self.continuescan_pixel_size_label.setText(_translate("MainWindow", "0 x 0 microns")) + self.label_84.setText(_translate("MainWindow", "Number of Angles:")) + self.label_80.setText(_translate("MainWindow", "Friendly Name:")) + self.pushButton.setText(_translate("MainWindow", "Browse Directory")) + self.label_73.setText(_translate("MainWindow", "Resume an Existing Scan:")) + self.label_90.setText(_translate("MainWindow", "Y-Delta:")) + self.continuescan_y_delta_label.setText(_translate("MainWindow", "Unknown")) + self.continuescan_last_complete_scan.setText(_translate("MainWindow", "0")) + self.continuescan_y_start_label.setText(_translate("MainWindow", "Unknown")) + self.label_86.setText(_translate("MainWindow", "Last Successfully\n" +"Scanned Angle:")) + self.continuescan_friendlyname.setText(_translate("MainWindow", "Unknown")) + self.label_87.setText(_translate("MainWindow", "X-Delta:")) + self.label_82.setText(_translate("MainWindow", "X-Start:")) + self.pushButton_2.setText(_translate("MainWindow", "Read Directory and Load Metadata")) + self.continuescan_resume_scans.setText(_translate("MainWindow", "Resume Scanning")) + self.label_102.setText(_translate("MainWindow", "Kinematics Info:")) + self.label_105.setText(_translate("MainWindow", "XPos:")) + self.scanning_x_position_label.setText(_translate("MainWindow", "000.00")) + self.label_109.setText(_translate("MainWindow", "YPos:")) + self.scanning_y_position_label.setText(_translate("MainWindow", "000.00")) + self.label_103.setText(_translate("MainWindow", "State:")) + self.scanning_stage_state_label.setText(_translate("MainWindow", "IDLE")) + self.label_111.setText(_translate("MainWindow", "Genesis Info:")) + self.label_116.setText(_translate("MainWindow", "State:")) + self.scanning_vis_status_label.setText(_translate("MainWindow", "EMISSION")) + self.label_118.setText(_translate("MainWindow", "Power:")) + self.scanning_vis_power_label.setText(_translate("MainWindow", "000mW")) + self.label_120.setText(_translate("MainWindow", "Temp:")) + self.scanning_vis_temperature_label.setText(_translate("MainWindow", "0C")) + self.label_112.setText(_translate("MainWindow", "Error:")) + self.scanning_vis_error_label.setText(_translate("MainWindow", "NONE")) + self.label_122.setText(_translate("MainWindow", "Helios Info:")) + self.label_125.setText(_translate("MainWindow", "State:")) + self.scanning_ir_status_label.setText(_translate("MainWindow", "EMISSION")) + self.label_131.setText(_translate("MainWindow", "Frequency:")) + self.scanning_ir_freq_label.setText(_translate("MainWindow", "20,000Hz")) + self.label_133.setText(_translate("MainWindow", "Pump Current:")) + self.scanning_ir_current_label.setText(_translate("MainWindow", "1500mA")) + self.label_135.setText(_translate("MainWindow", "Head Hours:")) + self.scanning_ir_headhours_label.setText(_translate("MainWindow", "0.0")) + self.label_123.setText(_translate("MainWindow", "Head Temp:")) + self.scanning_ir_temp_label.setText(_translate("MainWindow", "0C")) + self.label_142.setText(_translate("MainWindow", "Error:")) + self.scanning_ir_error_label.setText(_translate("MainWindow", "NONE")) + self.label_137.setText(_translate("MainWindow", "PulseDecimator:")) + self.label_140.setText(_translate("MainWindow", "Status:")) + self.scanning_pulsedivider_status_label.setText(_translate("MainWindow", "ACTIVE")) + self.label_138.setText(_translate("MainWindow", "Divider:")) + self.scanning_pulsedivider_divider_label.setText(_translate("MainWindow", "/10")) + self.label_99.setText(_translate("MainWindow", "Scanning in Progress:")) + self.label_146.setText(_translate("MainWindow", "Scan Preview (DC Only):")) + self.scanning_estimated_remaining_time_label.setText(_translate("MainWindow", "0h 0min")) + self.label_101.setText(_translate("MainWindow", "Scan Progress:")) + self.label_100.setText(_translate("MainWindow", "Overall Progress:")) + self.label_144.setText(_translate("MainWindow", "Estimated Remaining Time:")) + self.abort_scan_button.setText(_translate("MainWindow", "ABORT SCAN"))