pre uc480 integration

This commit is contained in:
Thomas Ales [M S E]
2026-05-22 09:38:39 -05:00
parent 43e7b99512
commit a0e0151b5d
30 changed files with 12557 additions and 41 deletions
+25
View File
@@ -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
+145
View File
@@ -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)
+37
View File
@@ -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
+545
View File
@@ -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()
View File
+7
View File
@@ -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"
}
+574
View File
@@ -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())
+252
View File
@@ -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()
+4 -1
View File
@@ -1,9 +1,12 @@
{ {
"genesis_laser": {
"com_port": "/dev/ttyUSB0"
},
"detection_laser": { "detection_laser": {
"scan_power_mw": "125" "scan_power_mw": "125"
}, },
"generation_laser": { "generation_laser": {
"com_port": "/dev/ttyUSB0", "com_port": "/dev/ttyACM0",
"frequency_hz": "20000", "frequency_hz": "20000",
"pump_diode_current_ma": "750", "pump_diode_current_ma": "750",
"focusing_frequency_hz": "20000", "focusing_frequency_hz": "20000",
Binary file not shown.
+193
View File
@@ -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
+142 -23
View File
@@ -126,17 +126,34 @@ class HeliosLaser:
Send a query and read response. Send a query and read response.
Args: Args:
command: ASCII query command (without CR) command: ASCII query command (without CR or ?)
Returns: Returns:
Response string or None if error Response value string or None if error
""" """
if not self._send_command(command):
return None
try: 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}") 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 return response
except Exception as e: except Exception as e:
@@ -160,7 +177,12 @@ class HeliosLaser:
# Convert frequency to period in nanoseconds # Convert frequency to period in nanoseconds
period_ns = int(1e9 / frequency) 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) return self._send_command(command)
def set_current_ma(self, current: int) -> bool: def set_current_ma(self, current: int) -> bool:
@@ -168,16 +190,16 @@ class HeliosLaser:
Set pump diode current in mA. Set pump diode current in mA.
Args: Args:
current: Current in mA (0 - 7000) current: Current in mA (0 - 2000 for this model)
Returns: Returns:
True if successful True if successful
""" """
if not (0 <= current <= 7000): if not (0 <= current <= 2000):
logger.error(f"Current {current} mA out of range (0-7000)") logger.error(f"Current {current} mA out of range (0-2000)")
return False return False
command = f"PC={current}" command = f"LDS {current}"
return self._send_command(command) return self._send_command(command)
def set_pulse_mode(self, mode: PulseMode) -> bool: def set_pulse_mode(self, mode: PulseMode) -> bool:
@@ -190,7 +212,7 @@ class HeliosLaser:
Returns: Returns:
True if successful True if successful
""" """
command = f"PM={mode.value}" command = f"LDG {mode.value}"
return self._send_command(command) return self._send_command(command)
def set_laser_enable(self, enable: bool) -> bool: def set_laser_enable(self, enable: bool) -> bool:
@@ -203,7 +225,7 @@ class HeliosLaser:
Returns: Returns:
True if successful True if successful
""" """
command = f"LE={1 if enable else 0}" command = f"LDO {1 if enable else 0}"
success = self._send_command(command) success = self._send_command(command)
if success: if success:
@@ -219,12 +241,12 @@ class HeliosLaser:
Returns: Returns:
True if laser is enabled True if laser is enabled
""" """
response = self._query("LE?") response = self._query("LDO")
if response: if response:
try: try:
return int(response) == 1 return int(response) == 1
except ValueError: except ValueError:
logger.error(f"Invalid response for LE?: {response}") logger.error(f"Invalid response for LDO: {response}")
return False return False
def get_frequency_hz(self) -> Optional[int]: def get_frequency_hz(self) -> Optional[int]:
@@ -234,13 +256,13 @@ class HeliosLaser:
Returns: Returns:
Frequency in Hz or None if error Frequency in Hz or None if error
""" """
response = self._query("FP?") response = self._query("LDF")
if response: if response:
try: try:
period_ns = int(response) period_ns = int(response)
return int(1e9 / period_ns) return int(1e9 / period_ns)
except (ValueError, ZeroDivisionError): except (ValueError, ZeroDivisionError):
logger.error(f"Invalid response for FP?: {response}") logger.error(f"Invalid response for LDF: {response}")
return None return None
def get_current_ma(self) -> Optional[int]: def get_current_ma(self) -> Optional[int]:
@@ -250,12 +272,12 @@ class HeliosLaser:
Returns: Returns:
Current in mA or None if error Current in mA or None if error
""" """
response = self._query("PC?") response = self._query("LDS")
if response: if response:
try: try:
return int(response) return int(response)
except ValueError: except ValueError:
logger.error(f"Invalid response for PC?: {response}") logger.error(f"Invalid response for LDS: {response}")
return None return None
def get_power_mw(self) -> Optional[float]: def get_power_mw(self) -> Optional[float]:
@@ -265,12 +287,12 @@ class HeliosLaser:
Returns: Returns:
Power in mW or None if error Power in mW or None if error
""" """
response = self._query("PO?") response = self._query("HMP")
if response: if response:
try: try:
return float(response) return float(response)
except ValueError: except ValueError:
logger.error(f"Invalid response for PO?: {response}") logger.error(f"Invalid response for HMP: {response}")
return None return None
def get_controller_serial(self) -> Optional[str]: def get_controller_serial(self) -> Optional[str]:
@@ -280,7 +302,7 @@ class HeliosLaser:
Returns: Returns:
Serial number string or None if error Serial number string or None if error
""" """
return self._query("SN?") return self._query("CSR")
def get_head_serial(self) -> Optional[str]: def get_head_serial(self) -> Optional[str]:
""" """
@@ -289,7 +311,104 @@ class HeliosLaser:
Returns: Returns:
Serial number string or None if error 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): def __del__(self):
"""Destructor - ensure cleanup""" """Destructor - ensure cleanup"""
+6 -1
View File
@@ -265,6 +265,11 @@ class ThorlabsServoDriver():
if(msg['status_bits'] & StatusBits.MOT_SB_HOMED): if(msg['status_bits'] & StatusBits.MOT_SB_HOMED):
self.am_homed[ch] = True 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): def _update0x0464(self, msg):
''' '''
_update0x0464 - internal function for MOVE_COMPLETED messages. _update0x0464 - internal function for MOVE_COMPLETED messages.
@@ -344,7 +349,7 @@ class ThorlabsServoDriver():
else: else:
raise ValueError("I don't know that axis!") 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) destination=axis, source=0x01)
return return
+7 -4
View File
@@ -1411,10 +1411,13 @@ class TektronixOscilloscopeBase:
if not self.get_fastframe_state(): if not self.get_fastframe_state():
raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)") raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)")
# Get the frame count # Use the number of frames actually acquired, not the configured maximum.
frame_count = self.get_fastframe_count() # If the stage stops early, fewer triggers arrive and the scope captures
if frame_count <= 0: # fewer frames than configured — reading the configured count would block.
raise RuntimeError(f"Invalid FastFrame count: {frame_count}") 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 # Send a single CURVe? query - scope will return all frames
self.write("CURVe?") self.write("CURVe?")
+66 -12
View File
@@ -4,11 +4,14 @@ Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library.
Provides camera control, live streaming, and image capture capabilities. Provides camera control, live streaming, and image capture capabilities.
""" """
import time
import numpy as np import numpy as np
from pyueye import ueye from pyueye import ueye
from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtGui import QImage from PyQt6.QtGui import QImage
import logging import logging
import threading
from contextlib import contextmanager
from typing import Optional, Tuple from typing import Optional, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,17 +27,21 @@ class UC480Camera(QObject):
frame_ready = pyqtSignal(QImage) # Emitted when a new frame is captured frame_ready = pyqtSignal(QImage) # Emitted when a new frame is captured
error_occurred = pyqtSignal(str) # Emitted when an error occurs 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. Initialize the uC480 camera driver.
Args: Args:
camera_id: Camera ID (0 for first available camera) camera_id: Camera ID (1-based; use is_GetCameraList to find IDs)
""" """
super().__init__() super().__init__()
self.camera_id = camera_id 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_initialized = False
self.is_capturing = False self.is_capturing = False
@@ -55,6 +62,9 @@ class UC480Camera(QObject):
self.bytes_per_pixel = 3 self.bytes_per_pixel = 3
self.color_mode = ueye.IS_CM_BGR8_PACKED 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: def initialize(self) -> bool:
""" """
Initialize the camera and allocate memory. Initialize the camera and allocate memory.
@@ -63,12 +73,23 @@ class UC480Camera(QObject):
True if successful, False otherwise True if successful, False otherwise
""" """
try: try:
# Initialize camera # Initialize camera. After is_ExitCamera the UI124x series
ret = ueye.is_InitCamera(self.h_cam, None) # resets and re-enumerates on USB (firmware reload), so retry
if ret != ueye.IS_SUCCESS: # for up to ~10 s if IS_CANT_OPEN_DEVICE is returned.
logger.error(f"Failed to initialize camera: {ret}") for attempt in range(20):
self.error_occurred.emit(f"Failed to initialize camera: {ret}") ret = ueye.is_InitCamera(self.h_cam, None)
return False 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 # Get sensor info
ret = ueye.is_GetSensorInfo(self.h_cam, self.sensor_info) ret = ueye.is_GetSensorInfo(self.h_cam, self.sensor_info)
@@ -161,9 +182,12 @@ class UC480Camera(QObject):
self.mem_ptr = None self.mem_ptr = None
if self.is_initialized: if self.is_initialized:
ueye.is_ExitCamera(self.h_cam) ret = ueye.is_ExitCamera(self.h_cam)
self.is_initialized = False 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: def start_capture(self) -> bool:
""" """
@@ -201,14 +225,37 @@ class UC480Camera(QObject):
return True return True
ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT) 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: if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to stop capture: {ret}") logger.error(f"Failed to stop capture: {ret}")
return False return False
self.is_capturing = False
logger.info("Video capture stopped") logger.info("Video capture stopped")
return True 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]: def get_frame(self) -> Optional[QImage]:
""" """
Capture a single frame from the camera. Capture a single frame from the camera.
@@ -405,6 +452,13 @@ class UC480Camera(QObject):
if ret == ueye.IS_SUCCESS: if ret == ueye.IS_SUCCESS:
logger.debug(f"Master gain set to {master_gain}") logger.debug(f"Master gain set to {master_gain}")
return True 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: else:
logger.error(f"Failed to set gain: {ret}") logger.error(f"Failed to set gain: {ret}")
return False return False
+217
View File
@@ -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()
+71
View File
@@ -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()
+985
View File
@@ -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 '<no response>'}")
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}: <read error>")
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()
+55
View File
@@ -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.
+395
View File
@@ -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'))
+6
View File
@@ -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
+76
View File
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>654</width>
<height>754</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QWidget" name="uc480_display_area" native="true">
<property name="minimumSize">
<size>
<width>640</width>
<height>640</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>640</width>
<height>640</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Exposure:</string>
</property>
</widget>
</item>
<item>
<widget class="QSlider" name="uc480_exposure_slider">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Gain:</string>
</property>
</widget>
</item>
<item>
<widget class="QSlider" name="uc480_gain_slider">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="uc480_close_window_btn">
<property name="text">
<string>Close Window</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
+963
View File
@@ -0,0 +1,963 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1016</width>
<height>1184</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label">
<property name="font">
<font>
<family>Noto Sans Condensed Medium</family>
<pointsize>36</pointsize>
</font>
</property>
<property name="text">
<string>Scanengine3 - AUI</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_8">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>Geometry Data</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="3" column="3">
<widget class="QLabel" name="label_26">
<property name="text">
<string>Scan Save Directory:</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLineEdit" name="y_start_edit">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_start_edit">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_3">
<property name="text">
<string>YS:</string>
</property>
</widget>
</item>
<item row="1" column="4">
<widget class="QLineEdit" name="y_delta_edit">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QLabel" name="label_5">
<property name="text">
<string>YD:</string>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QLabel" name="label_4">
<property name="text">
<string>XD:</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QLineEdit" name="num_angles_edit">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_25">
<property name="text">
<string>Scan File Prefix:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLabel" name="label_6">
<property name="text">
<string>NumAngles:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_2">
<property name="text">
<string>XS:</string>
</property>
</widget>
</item>
<item row="3" column="1" colspan="2">
<widget class="QLineEdit" name="scan_prefix_edit">
<property name="maximumSize">
<size>
<width>350</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="2" column="4">
<widget class="QLineEdit" name="row_spacing_edit">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QLabel" name="label_7">
<property name="text">
<string>RowSpacing:</string>
</property>
</widget>
</item>
<item row="0" column="4">
<widget class="QLineEdit" name="x_delta_edit">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="1" column="0">
<spacer name="horizontalSpacer_8">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="4" colspan="2">
<widget class="QLineEdit" name="scan_save_dir_edit">
<property name="maximumSize">
<size>
<width>350</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item row="3" column="6">
<widget class="QPushButton" name="save_dir_browse_btn">
<property name="text">
<string>Browse</string>
</property>
</widget>
</item>
<item row="1" column="5">
<spacer name="horizontalSpacer_7">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_16">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>Communication Settings:</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_2">
<item row="1" column="2">
<widget class="QPushButton" name="bbd202_connect_toggle">
<property name="text">
<string>Connect</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="t3r_comport_edit"/>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="bbd202_comport_edit"/>
</item>
<item row="2" column="2">
<widget class="QPushButton" name="oscope_connect_toggle">
<property name="text">
<string>Connect</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_9">
<property name="text">
<string>T3R Port:</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QPushButton" name="t3r_connect_toggle">
<property name="text">
<string>Connect</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_10">
<property name="text">
<string>BBD202 Port:</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_22">
<property name="text">
<string>Oscilloscope IP Address:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="oscope_ip_edit"/>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_17">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>T3RSL Manual Controls:</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<spacer name="horizontalSpacer_5">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="t3r_enable_t1_btn">
<property name="text">
<string>Enable T1</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
<widget class="QPushButton" name="t3r_enable_t2_btn">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Enable T2</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QPushButton" name="t3r_enable_t3_btn">
<property name="text">
<string>Enable T3</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="4">
<spacer name="horizontalSpacer_6">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_12">
<property name="text">
<string>T1 Axis</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_13">
<property name="text">
<string>T2 Axis</string>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QLabel" name="label_14">
<property name="text">
<string>T3 Axis</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="jog_t1_up_btn">
<property name="text">
<string>Up</string>
</property>
</widget>
</item>
<item row="2" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
<widget class="QPushButton" name="jog_t2_up_btn">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Up</string>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QPushButton" name="jog_t3_up_btn">
<property name="text">
<string>Up</string>
</property>
</widget>
</item>
<item row="3" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<property name="sizeConstraint">
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
</property>
<item>
<widget class="QLabel" name="label_20">
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Jog Speed:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="t3r_jog_speed_edit">
<property name="maximumSize">
<size>
<width>75</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="1">
<widget class="QPushButton" name="jog_t1_down_btn">
<property name="text">
<string>Down</string>
</property>
</widget>
</item>
<item row="4" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
<widget class="QPushButton" name="jog_t2_down_btn">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Down</string>
</property>
</widget>
</item>
<item row="4" column="3">
<widget class="QPushButton" name="jog_t3_down_btn">
<property name="text">
<string>Down</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QPushButton" name="jog_gr_ccw_btn">
<property name="text">
<string>CCW</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="QLabel" name="label_15">
<property name="text">
<string>GR Axis</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="5" column="3">
<widget class="QPushButton" name="jog_gr_cw_btn">
<property name="text">
<string>CW</string>
</property>
</widget>
</item>
<item row="5" column="4">
<widget class="QPushButton" name="t3r_enable_gr_btn">
<property name="text">
<string>Enable GR</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_t3r_current">
<item row="0" column="0">
<widget class="QLabel" name="label_t3r_cur_header">
<property name="text">
<string>Motor Current (mA):</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_t3r_cur_t1">
<property name="text">
<string>T1</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QSpinBox" name="t3r_current_t1_spin">
<property name="minimum">
<number>0</number>
</property>
<property name="maximum">
<number>2000</number>
</property>
<property name="value">
<number>600</number>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="t3r_set_current_t1_btn">
<property name="text">
<string>Set T1</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_t3r_cur_t2">
<property name="text">
<string>T2</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="t3r_current_t2_spin">
<property name="minimum">
<number>0</number>
</property>
<property name="maximum">
<number>2000</number>
</property>
<property name="value">
<number>600</number>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="t3r_set_current_t2_btn">
<property name="text">
<string>Set T2</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_t3r_cur_t3">
<property name="text">
<string>T3</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QSpinBox" name="t3r_current_t3_spin">
<property name="minimum">
<number>0</number>
</property>
<property name="maximum">
<number>2000</number>
</property>
<property name="value">
<number>600</number>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QPushButton" name="t3r_set_current_t3_btn">
<property name="text">
<string>Set T3</string>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QLabel" name="label_t3r_cur_gr">
<property name="text">
<string>GR</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QSpinBox" name="t3r_current_gr_spin">
<property name="minimum">
<number>0</number>
</property>
<property name="maximum">
<number>2000</number>
</property>
<property name="value">
<number>600</number>
</property>
</widget>
</item>
<item row="3" column="3">
<widget class="QPushButton" name="t3r_set_current_gr_btn">
<property name="text">
<string>Set GR</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_18">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>MLS203-1 Manual Controls:</string>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout_4">
<item row="0" column="4">
<widget class="QPushButton" name="bbd_enable_all_btn">
<property name="text">
<string>Toggle Axes Enable</string>
</property>
</widget>
</item>
<item row="2" column="2">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="label_19">
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Jog Speed:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="lineEdit_10">
<property name="maximumSize">
<size>
<width>75</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="4" alignment="Qt::AlignmentFlag::AlignLeft">
<widget class="QLabel" name="bbd_current_y_position_indicator">
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>24</pointsize>
</font>
</property>
<property name="text">
<string>000.00</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item row="0" column="1" colspan="3">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="bbd_home_all_btn">
<property name="text">
<string>Home All Axes</string>
</property>
</widget>
</item>
<item row="1" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
<widget class="QPushButton" name="bbd_jog_y_pos_btn">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Y+</string>
</property>
</widget>
</item>
<item row="4" column="0" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="QLabel" name="label_21">
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>X Position:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item row="4" column="1" alignment="Qt::AlignmentFlag::AlignLeft">
<widget class="QLabel" name="bbd_current_x_position_indicator">
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>24</pointsize>
</font>
</property>
<property name="text">
<string>000.00</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="bbd_jog_x_neg_btn">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>X-</string>
</property>
</widget>
</item>
<item row="2" column="0">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
<widget class="QPushButton" name="bbd_jog_y_neg_btn">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Y-</string>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QPushButton" name="bbd_jog_x_pos_btn">
<property name="maximumSize">
<size>
<width>150</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>X+</string>
</property>
</widget>
</item>
<item row="2" column="4">
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="3" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="QLabel" name="label_23">
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Y Position:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QPushButton" name="bbd_set_current_start_btn">
<property name="text">
<string>Set Current Coords as Start Coords</string>
</property>
</widget>
</item>
<item row="5" column="4">
<widget class="QPushButton" name="bbd_set_delta_current_btn">
<property name="text">
<string>Calculate Delta (Current - Start)</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="show_camera_toggle">
<property name="text">
<string>Show Camera Window</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="start_scan_btn">
<property name="text">
<string>Start Scanning</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
<tabstops>
<tabstop>x_start_edit</tabstop>
<tabstop>y_start_edit</tabstop>
<tabstop>x_delta_edit</tabstop>
<tabstop>y_delta_edit</tabstop>
<tabstop>num_angles_edit</tabstop>
<tabstop>row_spacing_edit</tabstop>
<tabstop>scan_prefix_edit</tabstop>
<tabstop>scan_save_dir_edit</tabstop>
<tabstop>save_dir_browse_btn</tabstop>
<tabstop>t3r_comport_edit</tabstop>
<tabstop>t3r_connect_toggle</tabstop>
<tabstop>bbd202_comport_edit</tabstop>
<tabstop>bbd202_connect_toggle</tabstop>
<tabstop>oscope_ip_edit</tabstop>
<tabstop>oscope_connect_toggle</tabstop>
<tabstop>t3r_enable_t1_btn</tabstop>
<tabstop>t3r_enable_t2_btn</tabstop>
<tabstop>t3r_enable_t3_btn</tabstop>
<tabstop>jog_t1_up_btn</tabstop>
<tabstop>jog_t2_up_btn</tabstop>
<tabstop>jog_t3_up_btn</tabstop>
<tabstop>t3r_jog_speed_edit</tabstop>
<tabstop>jog_t1_down_btn</tabstop>
<tabstop>jog_t2_down_btn</tabstop>
<tabstop>jog_t3_down_btn</tabstop>
<tabstop>jog_gr_ccw_btn</tabstop>
<tabstop>jog_gr_cw_btn</tabstop>
<tabstop>t3r_enable_gr_btn</tabstop>
<tabstop>t3r_current_t1_spin</tabstop>
<tabstop>t3r_set_current_t1_btn</tabstop>
<tabstop>t3r_current_t2_spin</tabstop>
<tabstop>t3r_set_current_t2_btn</tabstop>
<tabstop>t3r_current_t3_spin</tabstop>
<tabstop>t3r_set_current_t3_btn</tabstop>
<tabstop>t3r_current_gr_spin</tabstop>
<tabstop>t3r_set_current_gr_btn</tabstop>
<tabstop>bbd_enable_all_btn</tabstop>
<tabstop>lineEdit_10</tabstop>
<tabstop>bbd_home_all_btn</tabstop>
<tabstop>bbd_jog_y_pos_btn</tabstop>
<tabstop>bbd_jog_x_neg_btn</tabstop>
<tabstop>bbd_jog_y_neg_btn</tabstop>
<tabstop>bbd_jog_x_pos_btn</tabstop>
<tabstop>bbd_set_current_start_btn</tabstop>
<tabstop>bbd_set_delta_current_btn</tabstop>
<tabstop>show_camera_toggle</tabstop>
<tabstop>start_scan_btn</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>
+153
View File
@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>867</width>
<height>388</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>48</pointsize>
</font>
</property>
<property name="text">
<string>SCANNING</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="label_2">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Current Scan Progress:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="current_scan_current_row_indicator">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>Row 0 of 999</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QProgressBar" name="current_scan_progbar">
<property name="value">
<number>24</number>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLabel" name="label_3">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Overall Scan Progress:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="overall_scan_current_angle_indicator">
<property name="font">
<font>
<family>Noto Sans Condensed SemiBold</family>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>Angle 0 of 18</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QProgressBar" name="overall_scan_progbar">
<property name="value">
<number>24</number>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="abort_btn">
<property name="font">
<font>
<family>Noto Sans Condensed ExtraBold</family>
<pointsize>36</pointsize>
</font>
</property>
<property name="text">
<string>ABORT</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
+2635
View File
File diff suppressed because it is too large Load Diff
Executable
+1500
View File
File diff suppressed because it is too large Load Diff
+213
View File
@@ -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. |
+1961
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
PyQt6==6.10.2
numpy==2.4.1
matplotlib==3.10.8
+1321
View File
File diff suppressed because it is too large Load Diff