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 0000000..817b089 Binary files /dev/null and b/docs/protocols/helios_register_flags.pdf differ diff --git a/genesis_worker.py b/genesis_worker.py new file mode 100644 index 0000000..b1d9995 --- /dev/null +++ b/genesis_worker.py @@ -0,0 +1,193 @@ +""" +Genesis Laser Worker Thread + +Manages Genesis laser connection in a separate thread to keep the UI responsive. +Provides async querying and status monitoring via Qt signals. +""" + +from PyQt6 import QtCore +from hardware.genesis_core import SerialComm, I2CProtocol, I2CDevices, LaserControl +import queue +import time +from typing import Optional + + +class GenesisCommand: + """Represents a genesis laser command""" + def __init__(self, cmd_type: str, **kwargs): + self.cmd_type = cmd_type + self.params = kwargs + + +class GenesisWorker(QtCore.QObject): + """ + Worker object for handling Genesis laser control in a separate thread. + + Signals: + connected: Emitted when laser connects successfully + disconnected: Emitted when laser disconnects + connection_failed: Emitted when connection fails (error_msg: str) + laser_info_updated: Emitted with laser status + query_completed: Emitted when a query operation completes (result: dict) + error_occurred: Emitted when an error occurs (error_msg: str) + """ + + # Signals + connected = QtCore.pyqtSignal() + disconnected = QtCore.pyqtSignal() + connection_failed = QtCore.pyqtSignal(str) + laser_info_updated = QtCore.pyqtSignal(dict) # Status information + query_completed = QtCore.pyqtSignal(dict) # Query result + error_occurred = QtCore.pyqtSignal(str) # Error message + + def __init__(self, port: str = "/dev/ttyUSB0", baudrate: int = 9600): + super().__init__() + self.serial_comm = SerialComm() + self.i2c_protocol = I2CProtocol(self.serial_comm) + self.i2c_devices = I2CDevices(self.i2c_protocol) + self.laser_control = LaserControl(self.i2c_devices) + + self.port = port + self.baudrate = baudrate + self.is_connected = False + self.command_queue = queue.Queue() + self.running = True + + # Last known laser state + self.last_laser_state = {} + + # Update interval for status polling + self.last_status_update_time = 0 + self.status_update_interval = 1.0 # seconds + + @QtCore.pyqtSlot() + def run(self): + """Main worker loop - processes commands from queue""" + print(f"Genesis laser worker thread started - connecting to {self.port}") + + # Try to connect on startup + if self.connect(): + self.connected.emit() + else: + error_msg = f"Failed to connect to Genesis laser on {self.port}" + print(error_msg) + self.connection_failed.emit(error_msg) + + while self.running: + try: + # Check for commands with timeout to allow periodic status updates + try: + cmd = self.command_queue.get(timeout=0.05) # 50ms timeout + self.process_command(cmd) + except queue.Empty: + pass + + # Periodically update status if connected + if self.is_connected: + current_time = time.time() + if current_time - self.last_status_update_time >= 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"))