when'd i last commit this pos?

This commit is contained in:
Thomas Ales [M S E]
2026-02-09 14:40:34 -06:00
parent fc43fbe4b0
commit 23f6331ba2
94 changed files with 14427 additions and 12178 deletions
View File
+222 -33
View File
@@ -1,57 +1,246 @@
# scanengine-3 # scanengine-3
A unified scanning and instrumentation control platform combining multiple hardware control modules. SRAS Scanning and Instrumentation Control Platform
## Overview ## Overview
scanengine-3 merges four previously separate projects into a single, cohesive platform: scanengine-3 is a unified platform for scanning acoustic microscopy and precision instrumentation control. It integrates multiple hardware control modules into a single cohesive PyQt6-based application.
- **nuescan**: SRAS scan planning and control software with PyQt6 GUI ### Key Features
- **pymso**: Tektronix oscilloscope control and data acquisition
- **pybbd202**: Thorlabs BBD203/MLS203 motor controller driver - **Stage Control**: ThorLabs BBD202/BBD203 motor controller with 3-axis positioning
- **pypewpewhops**: Coherent HOPS laser control via I2C - **Laser Systems**: Helios and Coherent HOPS laser control
- **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support
- **Scan Planning**: Automated raster scan generation and execution
- **Real-time Monitoring**: Live status updates and progress tracking
## Hardware Components
### Motion Control
- **ThorLabs BBD202/BBD203 Motor Controller**
- 3-channel APT protocol driver
- Precision positioning with encoder feedback
- Programmable velocity and acceleration
- Trigger output support for synchronized data acquisition
### Laser Systems
- **Helios Laser System**
- Frequency control (16.7-125 kHz)
- Current control (0-7000 mA)
- Multiple pulse modes
- Temperature and power monitoring
- **Coherent HOPS Laser**
- I2C/FTDI interface
- Power and modulation control
- Temperature monitoring
### Data Acquisition
- **Tektronix MSO/DPO Series Oscilloscopes**
- Direct socket communication (no VISA overhead)
- Fast-frame acquisition for high-speed scanning
- Multi-channel waveform capture
- Configurable triggering
### Microscope Systems
- **Genesis Microscope** (stub implementation)
- **T3R Timing Device** (stub implementation)
## Project Structure ## Project Structure
``` ```
scanengine-3/ scanengine-3/
├── nuescan/ # Main scan control application with GUI ├── scanengine/ # Main application package
├── pymso/ # Oscilloscope control module │ ├── __init__.py
├── pybbd202/ # Stage controller driver │ ├── app.py # Main application entry point
├── pypewpewhops/ # Laser control module │ ├── main_launcher.ui # Main launcher UI
├── requirements.txt # Unified dependencies │ ├── new_scan_wizard.ui # Scan wizard UI
└── README.md # This file │ └── options.ui # Options dialog UI
│
├── hardware/ # Hardware driver package
│ ├── __init__.py
│ ├── bbd202.py # ThorLabs stage controller
│ ├── uc480_camera.py # IDS/ThorLabs camera
│ ├── tektronix_base.py # Tektronix oscilloscope
│ ├── coherent_hops_laser.py # Coherent HOPS laser
│ └── genesis_core.py # Genesis laser core logic
│
├── scanning/ # Scan planning package
│ ├── __init__.py
│ ├── sc3_scan_model.py # Scan model
│ └── stage_scan_plan_generator.py # Scan path planning
│
├── tools/ # Standalone executable tools
│ ├── genesis_laser_control.py # Standalone Genesis app
│ └── genesis_laser_gui.py # Alternative Genesis GUI
│
├── tests/ # Test files
│ ├── __init__.py
│ ├── test_camera_integration.py
│ ├── test_genesis_connection.py
│ ├── test_genesis_protocol.py
│ ├── test_rotated_aoi.py
│ └── test_temperature_scaling.py
│
├── docs/ # Documentation
│ ├── hardware/ # Hardware documentation
│ │ ├── BBD203_CONNECTION_GUIDE.md
│ │ ├── BBD203_Communications_Protocol.md
│ │ ├── BBD203_DRIVER_README.md
│ │ ├── HELIOS_DRIVER_README.md
│ │ ├── GENESIS_LASER_README.md
│ │ └── laser_control_implementation_guide.md
│ └── protocols/ # Protocol specifications
│ ├── apt_communications_protocol.pdf
│ ├── helios_comms_protocol.pdf
│ └── thorlabs_mls_protocol.pdf
│
├── lib/ # Binary libraries (not in git)
│ ├── libueye_api64.so.3.82
│ ├── ueye_loader.c
│ └── ueye_loader.so
│
├── config.json # System configuration
├── requirements.txt # Python dependencies
├── README.md # This file
├── SETUP.md # Setup instructions
└── LICENSE # License file
``` ```
## Installation ## Quick Start
1. Create a virtual environment: ### Installation
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install dependencies: ```bash
```bash # Clone or navigate to project directory
pip install -r requirements.txt cd scanengine-3
```
# Create virtual environment (recommended)
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
### Running the Application
```bash
# Main GUI application
python -m scanengine.app
# Genesis laser control tool
python tools/genesis_laser_control.py
# Alternative Genesis laser GUI
python tools/genesis_laser_gui.py
```
## Dependencies ## Dependencies
- **PyQt6**: GUI framework for nuescan - **PyQt6** (>=6.4.0) - GUI framework
- **pyserial**: Serial communication for hardware interfaces - **pyserial** (>=3.5) - Serial communication
- **pyvisa/pyvisa-py**: VISA instrument control for oscilloscopes - **pyvisa** (>=1.13.0) - VISA instrument control
- **pyftdi**: FTDI device support for laser and stage controllers - **pyvisa-py** (>=0.7.0) - Pure Python VISA backend
- **pyftdi** (>=0.54.0) - FTDI USB device support
## Usage ## Usage Examples
Refer to the README files in each subdirectory for specific usage instructions: ### Stage Control
- `nuescan/README.md` - Main application usage ```python
- `pymso/` - Oscilloscope control examples from hardware.bbd202 import BBD202Controller
- `pybbd202/README.md` - Stage controller documentation
- `pypewpewhops/README.md` - Laser control documentation # BBD202/BBD203 controller example
controller = BBD202Controller()
controller.connect("/dev/ttyUSB0") # Serial port
# Use controller for stage operations
```
### Oscilloscope Acquisition
```python
from hardware.tektronix_base import TektronixOscilloscopeBase
scope = TektronixOscilloscopeBase()
scope.connect("192.168.1.100", 4000)
scope.set_acquire_mode("SAMPLE")
waveform = scope.get_curve_binary(1) # Channel 1
```
### Laser Control
```python
from hardware.coherent_hops_laser import CoherentHOPSLaser
laser = CoherentHOPSLaser()
laser.connect()
laser.set_power_level(50.0) # 50% power
laser.enable_output(True)
```
### Camera Control
```python
from hardware.uc480_camera import UC480Camera
camera = UC480Camera(camera_id=0)
camera.initialize()
camera.start_capture()
# Camera operations
```
## Configuration
### Stage Settings
Stage configuration is stored in `~/.nuescan/stage_settings.json`:
- Velocity and acceleration profiles
- Trigger configuration
- Axis limits and safety parameters
### Serial Port Configuration
Hardware devices are accessed via:
- **BBD202/203**: USB with automatic serial number detection
- **Helios**: RS-232 serial port (9600 baud, 8N1)
- **HOPS Laser**: FTDI USB (I2C interface)
- **Oscilloscope**: Ethernet/LXI (TCP socket on port 4000)
## Development
### Adding New Hardware
1. Create driver module in `hardware/` directory
2. Implement connection, control, and status methods
3. Add UI elements to main window or create new dialog
4. Connect signals in `main_window.py`
### Testing Without Hardware
All hardware modules include stub implementations or simulation modes. The GUI can be developed and tested without physical devices connected.
## Documentation
Detailed documentation available in project subdirectories:
- [BBD202/203 Driver Guide](docs/hardware/BBD203_DRIVER_README.md)
- [BBD202/203 Connection Guide](docs/hardware/BBD203_CONNECTION_GUIDE.md)
- [BBD202/203 Communications Protocol](docs/hardware/BBD203_Communications_Protocol.md)
- [Helios Laser Guide](docs/hardware/HELIOS_DRIVER_README.md)
- [Genesis Laser Guide](docs/hardware/GENESIS_LASER_README.md)
- [Laser Control Implementation Guide](docs/hardware/laser_control_implementation_guide.md)
- [Setup Instructions](SETUP.md)
## License ## License
Each module retains its original license. See individual subdirectories for license information. Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
See LICENSE file for full license text.
## Support
For issues, questions, or contributions, please refer to the project documentation or contact the development team.
## Version
scanengine-3 v0.1.0 - Initial unified release
+428
View File
@@ -0,0 +1,428 @@
# scanengine-3 Setup Guide
Complete installation and configuration guide for the SRAS scanning platform.
## System Requirements
### Software Requirements
- Python 3.8 or higher (3.10+ recommended)
- pip package manager
- Git (for version control)
### Operating Systems
- Linux (primary development platform)
- Windows 10/11
- macOS (limited testing)
### Hardware Requirements
Optional - application runs in simulation mode without hardware:
- USB ports for ThorLabs BBD202/203 and FTDI devices
- Serial (RS-232) port or USB-to-serial adapter for Helios laser
- Network connection for Tektronix oscilloscope (Ethernet/LXI)
## Installation
### 1. Create Virtual Environment
Using a virtual environment is strongly recommended to isolate dependencies.
```bash
# Navigate to project directory
cd scanengine-3
# Create virtual environment
python -m venv venv
# Activate virtual environment
# On Linux/macOS:
source venv/bin/activate
# On Windows:
venv\Scripts\activate
```
### 2. Install Python Dependencies
```bash
# Install all required packages
pip install -r requirements.txt
# Or install individually:
pip install PyQt6>=6.4.0
pip install pyserial>=3.5
pip install pyvisa>=1.13.0
pip install pyvisa-py>=0.7.0
pip install pyftdi>=0.54.0
```
### 3. Verify Installation
```bash
# Test Python imports
python -c "import PyQt6; import serial; import pyvisa; print('Dependencies OK')"
# List connected serial devices (optional)
python -c "import serial.tools.list_ports; print(list(serial.tools.list_ports.comports()))"
```
## Hardware Setup
### ThorLabs BBD202/BBD203 Motor Controller
**Connection:**
1. Connect BBD202/203 controller to PC via USB
2. Power on the controller
3. Note the serial number printed on the device (8 digits)
**Linux-specific:**
```bash
# Add user to dialout group for serial access
sudo usermod -a -G dialout $USER
# Log out and back in for changes to take effect
# Verify USB connection
lsusb | grep -i thorlabs
```
**Windows-specific:**
- Install ThorLabs APT software to get USB drivers
- Verify device appears in Device Manager under "Ports (COM & LPT)"
**First-time setup:**
```bash
# Run the stage test application
python stage_test_app.py
# Enter your BBD203 serial number
# Click "Connect" to test the connection
# Use "Home All Axes" to verify operation
```
### Helios Laser System
**Connection:**
1. Connect Helios laser to RS-232 serial port
2. Configure serial settings: 9600 baud, 8 data bits, no parity, 1 stop bit (8N1)
3. Note the COM port name (e.g., COM3 on Windows, /dev/ttyUSB0 on Linux)
**Linux-specific:**
```bash
# Identify serial port
ls -l /dev/ttyUSB* /dev/ttyS*
# Test connection (optional, if helios driver available)
# python -c "from hardware.helios_driver import HeliosDriver; d = HeliosDriver('/dev/ttyUSB0'); print('Connected:', d.connect())"
```
### Coherent HOPS Laser
**Connection:**
1. Connect HOPS laser to PC via FTDI USB cable
2. Laser communicates over I2C protocol through FTDI interface
**Driver installation:**
```bash
# Linux: Install libftdi (if not already present)
sudo apt install libftdi1-dev # Debian/Ubuntu
sudo dnf install libftdi-devel # Fedora
# Verify FTDI device
python -c "from pyftdi.ftdi import Ftdi; Ftdi.show_devices()"
```
**First-time setup:**
```bash
# Test laser connection
python -c "from hardware.coherent_hops_laser import CoherentHOPSLaser; laser = CoherentHOPSLaser(); print('Connected:', laser.connect())"
```
### Tektronix Oscilloscope
**Connection:**
1. Connect oscilloscope to network via Ethernet
2. Configure oscilloscope IP address (static recommended)
3. Enable LXI server on oscilloscope (Utility → I/O → Network → LXI)
**Network configuration:**
```bash
# Verify connectivity
ping <oscilloscope-ip>
# Test connection
python -c "from hardware.tektronix_base import TektronixOscilloscopeBase; scope = TektronixOscilloscopeBase(); scope.connect('<oscilloscope-ip>', 4000); print('Connected')"
```
## Running the Application
### Main Application
```bash
# Run main application
python -m scanengine.app
```
**On first launch:**
1. Main window opens with scan launcher interface
2. Configure system settings before starting scans
3. Use "Options" to configure hardware connections
### Genesis Laser Control Tools
For standalone Genesis laser control:
```bash
# Full-featured Genesis laser control app
python tools/genesis_laser_control.py
# Alternative Genesis GUI
python tools/genesis_laser_gui.py
```
Features:
- Current and power control
- Shutter and keyswitch control
- Real-time monitoring
- Interlock status
- Temperature readings
- Raw I2C packet interface
## Configuration
### Stage Settings
Stage configuration is automatically saved to:
```
~/.nuescan/stage_settings.json (Linux/macOS)
%USERPROFILE%\.nuescan\stage_settings.json (Windows)
```
Settings include:
- Velocity profiles per axis
- Acceleration profiles
- Trigger output configuration
- Last used serial number
**Manual editing:**
```json
{
"x_axis": {
"velocity": 2.0,
"acceleration": 5.0,
"trigger": {
"mode": 1,
"polarity": 0,
"start_pos_fwd": 0.0,
"interval_fwd": 1.0
}
}
}
```
### Application Settings
Main window settings (geometry, last used values) are stored in Qt settings:
```
~/.config/SRAS/nueScan.conf (Linux)
%APPDATA%\SRAS\nueScan.ini (Windows)
```
## Project Structure
```
scanengine-3/
│
├── scanengine/ # Main application package
│ ├── __init__.py
│ ├── app.py # Main application entry point
│ ├── main_launcher.ui # Main launcher UI
│ ├── new_scan_wizard.ui # Scan wizard UI
│ └── options.ui # Options dialog UI
│
├── hardware/ # Hardware driver package
│ ├── __init__.py
│ ├── bbd202.py # ThorLabs stage controller
│ ├── uc480_camera.py # IDS/ThorLabs camera
│ ├── tektronix_base.py # Tektronix oscilloscope
│ ├── coherent_hops_laser.py # Coherent HOPS laser
│ └── genesis_core.py # Genesis laser core logic
│
├── scanning/ # Scan planning package
│ ├── __init__.py
│ ├── sc3_scan_model.py # Scan model
│ └── stage_scan_plan_generator.py # Scan path planning
│
├── tools/ # Standalone executable tools
│ ├── genesis_laser_control.py # Standalone Genesis app
│ └── genesis_laser_gui.py # Alternative Genesis GUI
│
├── tests/ # Test files
│ ├── __init__.py
│ ├── test_camera_integration.py
│ ├── test_genesis_connection.py
│ ├── test_genesis_protocol.py
│ ├── test_rotated_aoi.py
│ └── test_temperature_scaling.py
│
├── docs/ # Documentation
│ ├── hardware/ # Hardware documentation
│ │ ├── BBD203_CONNECTION_GUIDE.md
│ │ ├── BBD203_Communications_Protocol.md
│ │ ├── BBD203_DRIVER_README.md
│ │ ├── HELIOS_DRIVER_README.md
│ │ ├── GENESIS_LASER_README.md
│ │ └── laser_control_implementation_guide.md
│ └── protocols/ # Protocol specifications
│ ├── apt_communications_protocol.pdf
│ ├── helios_comms_protocol.pdf
│ └── thorlabs_mls_protocol.pdf
│
├── lib/ # Binary libraries (not in git)
│ ├── libueye_api64.so.3.82
│ ├── ueye_loader.c
│ └── ueye_loader.so
│
├── config.json # System configuration
├── requirements.txt # Python dependencies
├── README.md # Project overview
├── SETUP.md # This file
└── LICENSE # License file
```
## Troubleshooting
### Common Issues
**1. Import Errors**
```
ModuleNotFoundError: No module named 'PyQt6'
```
**Solution:** Ensure virtual environment is activated and dependencies are installed
```bash
source venv/bin/activate # or venv\Scripts\activate on Windows
pip install -r requirements.txt
```
**2. Serial Port Access Denied (Linux)**
```
PermissionError: [Errno 13] Permission denied: '/dev/ttyUSB0'
```
**Solution:** Add user to dialout group
```bash
sudo usermod -a -G dialout $USER
# Log out and back in
```
**3. BBD202/203 Not Detected**
- Verify USB cable is connected and device is powered on
- Check serial number is correct (8 digits, case-sensitive)
- On Windows, verify ThorLabs APT drivers are installed
- Try different USB port
**4. Oscilloscope Connection Failed**
- Verify network connectivity with `ping`
- Ensure oscilloscope LXI server is enabled
- Check firewall settings (port 4000 must be open)
- Verify IP address is correct
**5. PyQt6 UI Loading Errors**
```
uic.loadUi() failed to load .ui file
```
**Solution:** Ensure .ui files are in same directory as main script, or check file paths
### Debug Mode
Enable verbose logging:
```python
# Add to __main__.py before creating QApplication
import logging
logging.basicConfig(level=logging.DEBUG)
```
All hardware modules print status messages:
- `DEBUG:` - Detailed operation information
- `INFO:` - Normal operations
- `WARNING:` - Potential issues
- `ERROR:` - Operation failures
## Development Workflow
### UI Modifications
1. Edit `.ui` files using Qt Designer:
```bash
designer nuescan_mainwindow.ui
```
2. UI files are loaded dynamically at runtime - no compilation needed
3. Access UI elements in code:
```python
self.ui.buttonName.clicked.connect(self.handler_method)
```
### Adding New Hardware
1. Create driver module in `hardware/` directory
2. Implement required methods:
- `connect()` / `disconnect()`
- `is_connected()`
- `get_status()`
3. Add to main window or create dedicated dialog
4. Update UI to include new hardware section
### Testing Without Hardware
All hardware drivers support operation without physical devices:
- Stage: Simulated position and status
- Lasers: Accept commands without hardware validation
- Oscilloscope: Can be tested with scope simulator
Run application normally - missing hardware will log warnings but won't prevent startup.
## Performance Optimization
### Fast Data Acquisition
For high-speed scanning with oscilloscope:
1. Use wired Ethernet (not Wi-Fi)
2. Set oscilloscope to 1 Gb Ethernet if available
3. Enable binary data transfer format
4. Use fast-frame mode for multi-point scans
### Stage Movement Optimization
For optimal scan performance:
1. Home all axes before starting scan
2. Set appropriate velocity limits (2-5 mm/s typical)
3. Configure trigger output for synchronized acquisition
4. Use continuous motion scans when possible
## Additional Resources
- [ThorLabs APT Protocol Manual](docs/protocols/apt_communications_protocol.pdf)
- [Helios Communication Protocol](docs/protocols/helios_comms_protocol.pdf)
- [ThorLabs MLS Protocol](docs/protocols/thorlabs_mls_protocol.pdf)
- [BBD202/203 Driver Documentation](docs/hardware/BBD203_DRIVER_README.md)
- [BBD202/203 Connection Guide](docs/hardware/BBD203_CONNECTION_GUIDE.md)
- [Helios Driver Documentation](docs/hardware/HELIOS_DRIVER_README.md)
- [Genesis Laser Documentation](docs/hardware/GENESIS_LASER_README.md)
- [Laser Control Implementation Guide](docs/hardware/laser_control_implementation_guide.md)
## License
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
## Support
For issues or questions:
1. Check troubleshooting section above
2. Review hardware-specific documentation
3. Examine console output for error messages
4. Contact development team
---
scanengine-3 v0.1.0
+28
View File
@@ -0,0 +1,28 @@
{
"detection_laser": {
"scan_power_mw": "125"
},
"generation_laser": {
"com_port": "/dev/ttyUSB0",
"frequency_hz": "20000",
"pump_diode_current_ma": "750",
"focusing_frequency_hz": "20000",
"focusing_pump_current_ma": "300"
},
"scanning_stage": {
"scan_velocity_mm_s": "200",
"scan_acceleration_mm_s2": "500",
"x_trigger_mode": 5,
"y_trigger_mode": 0,
"optical_axis_x_mm": "55",
"optical_axis_y_mm": "37.5"
},
"t3r": {
"com_port": "/dev/ttyUSB0"
},
"oscilloscope": {
"socket_address": "192.168.0.1",
"scratch_directory": "/opt/",
"save_location": "pc"
}
}
+161
View File
@@ -0,0 +1,161 @@
# Genesis SLM MX 532 Laser Control Application
## Overview
This application provides comprehensive control of the Genesis SLM MX 532 laser over serial port using the NXP I2C-over-serial protocol. It features a professional PyQt6 GUI with multiple tabs for basic controls, monitoring, advanced operations, and configuration.
## Features
### Tab 1: Basic Controls
- **Current Control**: Slider and numeric input for laser current (0-1023) with percentage display
- **Power Control**: Slider for power command setting
- **Digital Controls**: Toggle buttons for:
- Shutter (Open/Closed)
- Keyswitch (On/Off)
- Remote Enable (On/Off)
- Analog Input Enable (On/Off)
- Current Mode (On/Off)
- **Emergency Stop**: Red button that immediately closes shutter, sets current to 0, and disables keyswitch
### Tab 2: Monitoring
- Real-time sensor readings with auto-refresh capability (configurable interval)
- Displays:
- Actual current reading from ADC
- Interlock status (visual color-coded indicator)
- LDD (Laser Diode Driver) enable status
- Power supply glue input/output status
- Head DIO status
- Laser information display (model and wavelength)
### Tab 3: Advanced
- **Raw I2C Packet Sender**: Send custom I2C commands with hex input
- **Packet Capture Log**: Real-time log of all transmitted and received packets with timestamps
- Useful for debugging and development
### Tab 4: Configuration
- Serial port selection with auto-detection
- Baud rate configuration (default: 9600)
- Connect/Disconnect control
- DTR/RTS control line settings
- About section with protocol information
## Safety Features
1. **All controls disabled until connected** - Prevents accidental commands
2. **Shutter confirmation dialog** - Warns if opening shutter with current > 0
3. **Emergency stop** - Always enabled, immediately enters safe state
4. **Auto-safe state on disconnect** - Laser enters safe state when disconnecting
5. **Settings persistence** - Window geometry and last settings are saved
## Protocol Details
The application uses the NXP I2C-over-serial protocol with this packet format:
**Write packet:**
```
[0x53] [I2C_ADDR] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50]
```
**Read packet:**
```
[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50]
```
### I2C Devices
- **X9119** (0x52/0x53) - Digital potentiometer for current control
- **PCA9555** (various addresses) - I/O expanders for digital control
- **ADS7828** (0x90/0x91) - ADC for sensor readings
- **AD5254** (0x58) - Digital potentiometer for limits
- **M24C64** (0xa4/0xa5) - EEPROM for configuration storage
## Installation
1. Ensure dependencies are installed:
```bash
pip install -r requirements.txt
```
2. Connect the laser to your computer via serial port (typically /dev/ttyUSB0 on Linux)
3. Run the application:
```bash
python genesis_laser_control.py
```
## Usage
1. **Launch the application**
```bash
python genesis_laser_control.py
```
2. **Connect to the laser:**
- Go to the "Configuration" tab
- Select the correct serial port from the dropdown
- Verify baud rate is set to 9600 (default)
- Click "Connect"
3. **Basic operation:**
- Enable remote control using the "Remote: DISABLED" button
- Enable keyswitch if required
- Adjust current using the slider
- Open shutter when ready (confirmation dialog will appear if current > 0)
4. **Monitoring:**
- Go to the "Monitoring" tab
- Enable "Auto-refresh" to continuously update readings
- Adjust refresh interval as needed (default: 500ms)
5. **Emergency stop:**
- Click the red "EMERGENCY STOP" button on any tab
- This immediately closes shutter, sets current to 0, and disables keyswitch
6. **Disconnecting:**
- Click "Disconnect" in the Configuration tab
- Laser will automatically enter safe state before disconnecting
## Troubleshooting
### Connection Issues
- Verify serial port permissions: `sudo usermod -a -G dialout $USER` (logout and login)
- Check cable connection and power
- Verify correct port in Configuration tab
- Try refreshing ports list
### Communication Errors
- Check packet log in Advanced tab for detailed TX/RX
- Verify baud rate is 9600
- Ensure no other programs are using the serial port
- Try power cycling the laser
### Interlock Faults
- Check physical interlock connections
- Verify interlock status in Monitoring tab
- Ensure all safety covers are in place
## Code Structure
The application is organized into several classes:
- **SerialComm**: Low-level serial port communication
- **I2CProtocol**: NXP packet construction and parsing
- **I2CDevices**: I2C device-specific functions (PCA9555, X9119, ADS7828)
- **LaserControl**: High-level laser control operations
- **MainWindow**: PyQt6 GUI and user interaction
## Development
To modify the application:
1. **Adding new I2C devices**: Extend the `I2CDevices` class
2. **Adding new controls**: Add methods to `LaserControl` and corresponding UI elements
3. **Custom commands**: Use the Raw I2C Packet Sender in the Advanced tab for testing
## License
See LICENSE file for details.
## Support
For issues or questions, please refer to the main project documentation.
@@ -0,0 +1,554 @@
# Genesis SLM MX 532 Laser Control - PyQt6 Implementation Guide
## Protocol Overview
The laser uses NXP I2C-over-serial protocol on /dev/ttyUSB0 at 9600 8N1.
### Packet Format
**Write:**
```
[0x53] [I2C_ADDR_WRITE] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50]
```
**Read:**
```
[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50]
```
## I2C Device Map
| Device | Address | Write | Read | Purpose |
|--------|---------|-------|------|---------|
| X9119 (current) | 0x152 | 0x52 | 0x53 | Laser current control |
| X9119 (photo) | 0x152 | 0x52 | 0x53 | Power feedback control |
| PCA9555 (PS DIO) | 0x140 | 0x40 | 0x41 | Power supply digital I/O |
| PCA9555 (Head DIO) | 0x144 | 0x44 | 0x45 | Head digital I/O |
| PCA9555 (PS Glue In) | 0x148 | 0x48 | 0x49 | Power supply glue logic input |
| PCA9555 (PS Glue Out) | 0x14a | 0x4a | 0x4b | Power supply glue logic output |
| AD5254 (current limit) | 0x158 | 0x58 | 0x59 | Current limit potentiometer |
| AD5254 (photo limit) | 0x158 | 0x58 | 0x59 | Photo limit potentiometer |
| ADS7828 (ADC) | 0x190 | 0x90 | 0x91 | Analog sensor readings |
| M24C64 (EEPROM) | 0x2a4 | 0xa4 | 0xa5 | Configuration storage |
| STM32 (MCU) | 0x2ae | 0xae | 0xaf | Microcontroller |
| DS1682 (hour meter) | 0x1d6 | 0xd6 | 0xd7 | Hour meter |
## Control Commands (from SendI2cCommand)
### SET Commands with Vtable Offsets
| Command | Offset | Device | Details |
|---------|--------|--------|---------|
| PCMD= | 0xf8 | X9119 @ 0x152 | Power command, cmd 0xa0, 0-1023 |
| PMEM= | 0x100 | X9119 @ 0x152 | Power memory (writes to NV) |
| CCMD= | 0xe8 | X9119 @ 0x152 | Current command, cmd 0xa0, 0-1023 |
| SHCMD= | 0x54 | PCA9555 @ 0x14a | Shutter, port 0, bit 0x01 |
| KSWCMD= | 0x60 | PCA9555 @ 0x14a | Keyswitch, port 0, bit 0x20 |
| CMODECMD= | 0x84 | PCA9555 @ 0x14a | Current mode, port 0, bit 0x04 |
| ANACMD= | 0x70 | PCA9555 @ 0x14a | Analog enable, port 0, bit 0x10 |
| REM= | 0x7c | PCA9555 @ 0x14a | Remote enable, port 0, bit 0x08 |
### QUERY Commands with Vtable Offsets
| Command | Offset | Returns |
|---------|--------|---------|
| ?TMAINCMD | 0xdc | Main temperature command |
| ?MAIND | 0xe0 | Main diode current |
| ?PSGLUEIN | 0x44 | PS glue input (PCA9555 @ 0x148) |
| ?PSGLUEOUT | 0x48 | PS glue output (PCA9555 @ 0x14a) |
| ?HEADDIO | 0x4c | Head DIO (PCA9555 @ 0x144) |
| ?WAVELENGTH | 0x14 | Wavelength reading |
| ?LASERMODEL | 0x10 | Model string |
| ?POWERUNITS | 0x110 | Power units string |
## Device-Specific Commands
### X9119 Digital Potentiometer
- **Write wiper:** Command 0xa0, 2 bytes (10-bit value 0-1023)
- **Valid range:** 0-1023
- **Error message:** "Valid code range is 0-1023"
### PCA9555 I/O Expander Registers
| Register | Address | Purpose |
|----------|---------|---------|
| Input Port 0 | 0x00 | Read input state |
| Input Port 1 | 0x01 | Read input state |
| Output Port 0 | 0x02 | Write output state |
| Output Port 1 | 0x03 | Write output state |
| Polarity Inv 0 | 0x04 | Invert polarity |
| Polarity Inv 1 | 0x05 | Invert polarity |
| Config Port 0 | 0x06 | Direction (0=out, 1=in) |
| Config Port 1 | 0x07 | Direction (0=out, 1=in) |
### PCA9555 @ 0x14a (Power Supply Glue Out) Bit Assignments
| Bit | Mask | Function |
|-----|------|----------|
| 0 | 0x01 | Shutter command |
| 2 | 0x04 | Current mode command |
| 3 | 0x08 | Remote enable command |
| 4 | 0x10 | Analog input enable |
| 5 | 0x20 | Keyswitch command |
### ADS7828 ADC Commands
- **Current actual:** Channel 0x84
- **Photo actual (alt):** Channel 0xe4
- **Main temp actual:** Channel 0x94
## Read Strategies (Critical!)
The DLL uses three different read strategies:
### 1. ReadOne - Single Read
```python
def i2c_read_one(addr, cmd, data_len):
"""Single read, no filtering. Use for digital I/O."""
return nxp_read(addr, cmd, 1 if cmd <= 0xFF else 2, data_len)
```
**Use for:** Digital I/O states (PCA9555 reads)
### 2. ReadMatchTwo - Match Until Consistent
```python
def i2c_read_match_two(addr, cmd, data_len, max_attempts=5):
"""Read until 2 values match (up to 5 attempts). Most reliable."""
readings = []
for attempt in range(max_attempts):
value = nxp_read(addr, cmd, 1 if cmd <= 0xFF else 2, data_len)
readings.append(value)
if readings.count(value) >= 2:
return value
time.sleep(0.01)
return readings[0]
```
**Use for:** EEPROM reads, configuration values
### 3. ReadDiscardHighLow - Median Filter
```python
def i2c_read_discard_high_low(addr, cmd, data_len):
"""Read 3 times, return median. Filters noise."""
readings = []
for _ in range(3):
value = nxp_read(addr, cmd, 1 if cmd <= 0xFF else 2, data_len)
readings.append(value)
time.sleep(0.01)
return sorted(readings)[1] # Median
```
**Use for:** ADC readings (current, temperature, voltage)
## Implementation Requirements
### Core Protocol Functions
```python
def nxp_write(i2c_addr_write, cmd, data, data_len):
"""
Build NXP I2C write packet.
Args:
i2c_addr_write: I2C device address (write bit cleared)
cmd: Command byte(s) - int for 1 byte, int for 2 bytes
data: Data value to write
data_len: 1, 2, or 4 bytes
Packet: [0x53][addr][len][cmd...][data...][0x50]
"""
pass
def nxp_read(i2c_addr_write, cmd, cmd_len, data_len):
"""
Build NXP I2C read packet.
Args:
i2c_addr_write: I2C device address (write bit cleared)
cmd: Command byte(s)
cmd_len: 1 or 2 bytes
data_len: Number of bytes to read
Packet: [0x53][addr][cmd_len][cmd...][0x53][addr|0x01][data_len][0x50]
Returns: Bytes read from device
"""
pass
def pca9555_read_port(addr, port):
"""Read PCA9555 port register (0x00-0x07)."""
return i2c_read_one(addr, port, 1)
def pca9555_write_port(addr, port, value):
"""Write PCA9555 port register."""
nxp_write(addr, port, value, 1)
def pca9555_set_bit(addr, port, bitmask, state):
"""
Set or clear specific bit on PCA9555.
Reads current value, modifies bit, writes back.
"""
current = pca9555_read_port(addr, 0x02 + port) # Output port
if state:
new_value = current | bitmask
else:
new_value = current & ~bitmask
pca9555_write_port(addr, 0x02 + port, new_value)
def x9119_write_wiper(addr, value):
"""Set X9119 wiper position (0-1023)."""
if value > 1023:
raise ValueError("X9119 value must be 0-1023")
nxp_write(addr, 0xa0, value, 2)
def ads7828_read(addr, channel):
"""Read ADS7828 ADC channel."""
return i2c_read_discard_high_low(addr, channel, 2)
```
### High-Level Control Functions
```python
def set_current(value):
"""CCMD= - Set laser current (0-1023)."""
x9119_write_wiper(0x52, value)
def set_power_cmd(value):
"""PCMD= - Set power command (0-1023)."""
x9119_write_wiper(0x52, value) # Same device, different context
def set_shutter(state):
"""SHCMD= - Control shutter (True=open, False=closed)."""
pca9555_set_bit(0x4a, 0, 0x01, state)
def set_keyswitch(state):
"""KSWCMD= - Control keyswitch (True=on, False=off)."""
pca9555_set_bit(0x4a, 0, 0x20, state)
def set_remote_enable(state):
"""REM= - Enable remote control (True=enabled)."""
pca9555_set_bit(0x4a, 0, 0x08, state)
def set_analog_enable(state):
"""ANACMD= - Enable analog input (True=enabled)."""
pca9555_set_bit(0x4a, 0, 0x10, state)
def set_current_mode(state):
"""CMODECMD= - Set current mode (True=enabled)."""
pca9555_set_bit(0x4a, 0, 0x04, state)
def get_current_actual():
"""Read actual current from ADC."""
raw = ads7828_read(0x90, 0x84)
# TODO: Scale based on AmpsFullscale
return raw * 0.000244140625 # Placeholder scaling
def get_main_temp():
"""Read main crystal temperature."""
return ads7828_read(0x90, 0x94)
def get_interlock_status():
"""Check interlock status."""
value = pca9555_read_port(0x48, 0x00) # Input port 0
return bool(value & 0x01)
def get_ldd_enable():
"""Check if laser diode driver is enabled."""
value = pca9555_read_port(0x40, 0x00)
return not bool(value & 0x01) # Inverted logic
```
## GUI Structure
### Tab 1: Basic Controls
- **Current Control**
- Slider: 0-1023
- Spinbox: Numeric input
- Label: Percentage (0-100%)
- Button: Set to 0 (quick disable)
- **Power Command**
- Slider: 0-1023
- Spinbox: Numeric input
- **Digital Controls (Toggle Buttons)**
- Shutter (Open/Closed) - Red when open
- Keyswitch (On/Off)
- Remote Enable (On/Off)
- Analog Input (On/Off)
- Current Mode (On/Off)
- **Emergency Stop**
- Large red button
- Always enabled (even when disconnected)
- Actions: Close shutter, current=0, keyswitch=off
- **Status Display**
- Actual current reading (live)
- Connection status indicator
### Tab 2: Monitoring
- **Real-Time Readings** (auto-refresh 500ms)
- Current Actual: [value] A
- Main Temperature: [value] °C
- SHG Temperature: [value] °C
- Interlock Status: [OK/FAULT] (colored indicator)
- LDD Enable: [ON/OFF]
- **System Info** (read once on connect)
- Laser Model: [from EEPROM]
- Wavelength: [value] nm
- Serial Number: [from EEPROM]
- **Controls**
- Refresh Rate slider (100ms - 2000ms)
- Enable/Disable auto-refresh checkbox
- Manual refresh button
### Tab 3: Advanced
- **Raw I2C Interface**
- Device Address (hex): [input]
- Command (hex): [input]
- Data (hex): [input]
- Data Length: [dropdown: 1/2/4 bytes]
- Buttons: [Write] [Read]
- Response display (hex)
- **Packet Monitor**
- Scrolling log of all TX/RX
- Format: `[HH:MM:SS.mmm] TX: 53 52 02 a0 01 00 50`
- Clear button
- Export to file button
- **EEPROM Tools**
- Warning label (red): "Caution: Can damage laser!"
- Read Location (hex): [input]
- Read Length: [input]
- [Read] button
- Data display
- Write protection checkbox
### Tab 4: Configuration
- **Serial Port**
- Dropdown: Auto-detect available ports
- Baud Rate: [dropdown: 9600 default]
- Data Bits: 8 (fixed)
- Parity: None (fixed)
- Stop Bits: 1 (fixed)
- **Connection**
- [Connect]/[Disconnect] button (toggle)
- DTR checkbox
- RTS checkbox
- Status: [Connected/Disconnected]
- **Debug Options**
- Show raw sensor values (before filtering) checkbox
- Packet timeout (ms): [input]
- Retry count: [input]
## Safety Features
### Pre-Flight Checks
Before opening shutter, verify:
1. Remote enable is ON
2. Keyswitch is ON
3. Interlock is OK
4. Current is set to safe value
5. Show confirmation dialog
### Automatic Safety
- All controls disabled until serial connected
- Emergency stop always enabled
- Auto-disable on serial errors
- Auto-close shutter on disconnect
- Set current to 0 on disconnect
- Watchdog timer - if no commands for 5 seconds, safe state
### Error Handling
- Catch all serial exceptions
- Display errors in status bar
- Log errors with timestamps
- Retry logic for critical operations
- Timeout handling (default 1 second)
## Code Organization
### Class Structure
```
LaserControlApp (QMainWindow)
├── SerialComm (QObject)
│ ├── Methods: connect(), disconnect(), write_packet(), read_packet()
│ └── Signals: connected, disconnected, error, data_received
│
├── I2CProtocol (QObject)
│ ├── Low-level: nxp_write(), nxp_read()
│ ├── Device-specific: pca9555_*, x9119_*, ads7828_*
│ └── Read strategies: read_one(), read_match_two(), read_discard_high_low()
│
├── LaserControl (QObject)
│ ├── High-level: set_current(), set_shutter(), etc.
│ ├── Monitoring: get_current_actual(), get_temps(), etc.
│ └── Safety: emergency_stop(), safe_state(), pre_flight_check()
│
└── UI Tabs
├── BasicControlTab (QWidget)
├── MonitoringTab (QWidget)
├── AdvancedTab (QWidget)
└── ConfigTab (QWidget)
```
### Constants (at top of file)
```python
# I2C Addresses (7-bit, shifted)
ADDR_X9119_CURRENT = 0x52
ADDR_PCA9555_PS_DIO = 0x40
ADDR_PCA9555_HEAD_DIO = 0x44
ADDR_PCA9555_PS_GLUE_IN = 0x48
ADDR_PCA9555_PS_GLUE_OUT = 0x4a
ADDR_AD5254 = 0x58
ADDR_ADS7828 = 0x90
ADDR_EEPROM = 0xa4
ADDR_STM32 = 0xae
# PCA9555 Bit Masks (0x14a)
BIT_SHUTTER = 0x01
BIT_CURRENT_MODE = 0x04
BIT_REMOTE_ENABLE = 0x08
BIT_ANALOG_ENABLE = 0x10
BIT_KEYSWITCH = 0x20
# X9119 Commands
CMD_X9119_WRITE_WIPER = 0xa0
# ADS7828 Channels
CHAN_CURRENT_ACTUAL = 0x84
CHAN_PHOTO_ACTUAL = 0xe4
CHAN_MAIN_TEMP = 0x94
# Serial Protocol
NXP_START_BYTE = 0x53
NXP_STOP_BYTE = 0x50
```
## Styling
Use QSS for professional appearance:
```python
STYLESHEET = """
QMainWindow {
background-color: #2b2b2b;
}
QPushButton {
background-color: #3c3c3c;
color: white;
border: 1px solid #555;
padding: 5px;
border-radius: 3px;
}
QPushButton:hover {
background-color: #4c4c4c;
}
QPushButton:pressed {
background-color: #2c2c2c;
}
QPushButton#emergency {
background-color: #cc0000;
font-weight: bold;
font-size: 14pt;
}
QPushButton#emergency:hover {
background-color: #ff0000;
}
QLabel#status_ok {
color: #00ff00;
font-weight: bold;
}
QLabel#status_warning {
color: #ffaa00;
font-weight: bold;
}
QLabel#status_error {
color: #ff0000;
font-weight: bold;
}
QSlider::groove:horizontal {
background: #3c3c3c;
height: 8px;
}
QSlider::handle:horizontal {
background: #5c5c5c;
width: 16px;
margin: -4px 0;
border-radius: 8px;
}
"""
```
## Testing Checklist
Before Monday demo:
- [ ] Serial connection/disconnection works
- [ ] Current slider sets X9119 correctly
- [ ] Shutter opens/closes (verify with read-back)
- [ ] Emergency stop works from any state
- [ ] Sensor readings update (current, temp)
- [ ] Error handling doesn't crash app
- [ ] All safety interlocks functional
- [ ] Confirm dialog before shutter open
- [ ] Auto-disable on disconnect
- [ ] Packet monitor shows correct data
## README Section for Top of File
```python
"""
Genesis SLM MX 532 Laser Control Application
=============================================
This application controls a Genesis SLM MX 532 laser via I2C-over-serial protocol.
PROTOCOL OVERVIEW:
- Serial: /dev/ttyUSB0 @ 9600 8N1
- Protocol: NXP I2C tunneled over serial
- Packet format: [0x53][addr][len][cmd][data][0x50]
SAFETY WARNINGS:
- Always close shutter before adjusting current
- Verify interlock status before opening shutter
- Use emergency stop if anything looks wrong
- Never bypass safety interlocks
USAGE:
1. Connect serial port in Configuration tab
2. Enable Remote and Keyswitch
3. Set current to desired level
4. Open shutter (with confirmation)
5. Monitor temperature and current
6. Close shutter when done
For more information, see protocol documentation.
Author: [Your Name]
Date: [Date]
License: [License]
"""
```
## Notes for Implementation
- All I2C addresses are 7-bit and need write bit cleared (& 0xFE) for writes
- Read addresses are write address | 0x01
- Multi-byte data is big-endian
- Always validate inputs before sending to hardware
- Use type hints throughout
- Add docstrings to all functions
- Log all I2C transactions for debugging
- Include unit tests for packet construction
- Make sure emergency stop can interrupt any operation
---
**CRITICAL:** Test thoroughly before Monday! The laser is expensive - safety first!
+6
View File
@@ -0,0 +1,6 @@
"""Hardware driver modules for ScanEngine-3"""
from .bbd202 import *
from .uc480_camera import *
from .tektronix_base import *
from .coherent_hops_laser import *
from .genesis_core import *
@@ -58,6 +58,9 @@ class MsgId(IntEnum):
MOT_GET_MOVEABSPARAMS = 0x0452 MOT_GET_MOVEABSPARAMS = 0x0452
MOT_MOVE_STOP = 0x0465 MOT_MOVE_STOP = 0x0465
MOT_MOVE_STOPPED = 0x0466 MOT_MOVE_STOPPED = 0x0466
MOT_SET_TRIGGER = 0x0500
MOT_REQ_TRIGGER = 0x0501
MOT_GET_TRIGGER = 0x0502
class ChannelEnableState(IntEnum): class ChannelEnableState(IntEnum):
@@ -78,6 +81,16 @@ class StopMode(IntEnum):
CONTROLLED = 0x02 CONTROLLED = 0x02
class TriggerMode(IntEnum):
"""Trigger mode values."""
DISABLED = 0x00
IN_OUT_RELATIVE_MOVE = 0x01
IN_OUT_ABSOLUTE_MOVE = 0x02
IN_OUT_HOME = 0x03
IN_OUT_STOP = 0x04
OUT_ONLY = 0x10
class MotorStatusBits(IntFlag): class MotorStatusBits(IntFlag):
"""Motor status bit flags.""" """Motor status bit flags."""
CWHARDLIMIT = 0x00000001 # Clockwise hard limit triggered CWHARDLIMIT = 0x00000001 # Clockwise hard limit triggered
@@ -183,9 +196,10 @@ class MotionController:
self._status_bits = {} # dest -> int (updated on every MOVE_COMPLETED or MOVE_STOPPED) self._status_bits = {} # dest -> int (updated on every MOVE_COMPLETED or MOVE_STOPPED)
self._status_bits_lock = threading.Lock() self._status_bits_lock = threading.Lock()
# CRITICAL: Controller stops responding after ~50 commands without periodic ACK # TX queue for serialized command sending - all outgoing data goes through this queue
self._ack_thread: Optional[threading.Thread] = None self._tx_queue: queue.Queue = queue.Queue()
self._ack_running = False self._tx_thread: Optional[threading.Thread] = None
self._tx_running = False
self._rx_thread: Optional[threading.Thread] = None self._rx_thread: Optional[threading.Thread] = None
self._rx_running = False self._rx_running = False
@@ -197,12 +211,32 @@ class MotionController:
self._waiters: dict[int, tuple[threading.Event, list]] = {} self._waiters: dict[int, tuple[threading.Event, list]] = {}
self._waiter_lock = threading.Lock() self._waiter_lock = threading.Lock()
# Pending move tracking for high-level motion control
self._pending_moves = {} # dest -> target_position (float)
self._pending_moves_lock = threading.Lock()
# Last error tracking
self._last_error: Optional[str] = None
self._last_error_lock = threading.Lock()
# Auto-ACK control - when True, ACKs are sent automatically in RX loop
# Set to False via stop_status_ack() for manual ACK mode during scanning
self._auto_ack_enabled = True
self._status_update_count_x = 0 # Track X status updates
self._status_update_count_y = 0 # Track Y status updates
def connect(self, enable_updates: bool = True) -> None: def connect(self, enable_updates: bool = True) -> None:
""" """
Open connection to the controller. Open connection to the controller.
Args: Args:
enable_updates: If True, enable status update messages and start listener thread enable_updates: If True, enable status update messages. ACKs are sent
reactively when status updates are received.
Note:
When enable_updates=True, the controller will send periodic status updates.
ACKs are automatically sent in response to each status update through
the TX queue, ensuring proper serialization with other commands.
""" """
self.ftdi.open_from_url(self.url) self.ftdi.open_from_url(self.url)
self.ftdi.set_baudrate(self.baudrate) self.ftdi.set_baudrate(self.baudrate)
@@ -213,33 +247,47 @@ class MotionController:
self._connected = True self._connected = True
time.sleep(0.1) time.sleep(0.1)
# Start TX thread for serialized command sending
self._tx_running = True
self._tx_thread = threading.Thread(target=self._tx_loop, daemon=True)
self._tx_thread.start()
self._rx_running = True self._rx_running = True
self._rx_thread = threading.Thread(target=self._rx_loop, daemon=True) self._rx_thread = threading.Thread(target=self._rx_loop, daemon=True)
self._rx_thread.start() self._rx_thread.start()
if enable_updates: if enable_updates:
self._send_raw(self._build_short_message(MsgId.HW_START_UPDATEMSGS)) # Send to both axis channels to enable updates from each
self._send_raw(self._build_short_message(MsgId.HW_START_UPDATEMSGS, dest=self.DEST_X_AXIS))
self._send_raw(self._build_short_message(MsgId.HW_START_UPDATEMSGS, dest=self.DEST_Y_AXIS))
else: else:
self._send_raw(self._build_short_message(MsgId.HW_STOP_UPDATEMSGS)) self._send_raw(self._build_short_message(MsgId.HW_STOP_UPDATEMSGS, dest=self.DEST_X_AXIS))
self._send_raw(self._build_short_message(MsgId.HW_STOP_UPDATEMSGS, dest=self.DEST_Y_AXIS))
time.sleep(0.1) time.sleep(0.1)
def disconnect(self) -> None: def disconnect(self) -> None:
"""Close connection to the controller.""" """Close connection to the controller."""
if self._connected: if self._connected:
self.stop_status_ack()
self._rx_running = False self._rx_running = False
if self._rx_thread: if self._rx_thread:
self._rx_thread.join(timeout=1.0) self._rx_thread.join(timeout=1.0)
self._rx_thread = None self._rx_thread = None
try: try:
self._send_raw(self._build_short_message(MsgId.HW_DISCONNECT)) # Send disconnect directly to FTDI since we're shutting down TX thread
with self._rx_lock:
self.ftdi.write_data(self._build_short_message(MsgId.HW_DISCONNECT))
time.sleep(0.05) time.sleep(0.05)
except: except:
pass pass
# Stop TX thread after sending disconnect
self._tx_running = False
if self._tx_thread:
self._tx_thread.join(timeout=1.0)
self._tx_thread = None
self.ftdi.close() self.ftdi.close()
self._connected = False self._connected = False
self._hw_info = None self._hw_info = None
@@ -257,11 +305,25 @@ class MotionController:
return header + data return header + data
def _send_raw(self, data: bytes) -> int: def _send_raw(self, data: bytes) -> int:
"""Send raw bytes to the controller.""" """Queue raw bytes to be sent to the controller via the TX thread."""
if not self._connected: if not self._connected:
raise ConnectionError("Not connected to controller") raise ConnectionError("Not connected to controller")
with self._rx_lock: self._tx_queue.put(data)
return self.ftdi.write_data(data) return len(data)
def _tx_loop(self) -> None:
"""TX thread - processes all outgoing commands from the queue."""
while self._tx_running:
try:
data = self._tx_queue.get(timeout=0.1)
if data is not None:
with self._rx_lock:
self.ftdi.write_data(data)
except queue.Empty:
pass
except Exception as e:
if self._tx_running:
print(f"TX error: {e}")
def _rx_loop(self) -> None: def _rx_loop(self) -> None:
"""Receiver thread loop - continuously reads and parses messages.""" """Receiver thread loop - continuously reads and parses messages."""
@@ -359,6 +421,48 @@ class MotionController:
self._encoder_counts[msg.source] = encoder_counts self._encoder_counts[msg.source] = encoder_counts
self._stage_positions[msg.source] = position_mm self._stage_positions[msg.source] = position_mm
# Process status updates - parse position and status data
if msg.msg_id == MsgId.MOT_GET_USTATUSUPDATE:
# Track per-axis counts
if msg.source == 0x21:
self._status_update_count_x += 1
count = self._status_update_count_x
elif msg.source == 0x22:
self._status_update_count_y += 1
count = self._status_update_count_y
else:
count = 0
# Debug: print every 10th status update with position
if count > 0 and count % 10 == 0:
axis = "X" if msg.source == 0x21 else "Y"
if len(msg.data) >= 14:
pos_counts = struct.unpack('<i', msg.data[2:6])[0]
status_bits = struct.unpack('<I', msg.data[10:14])[0]
pos_mm = pos_counts / self.ENCODER_COUNTS_PER_MM
moving = "MOVING" if (status_bits & 0x00000030) else "idle"
print(f" [STATUS] {axis}#{count} pos={pos_mm:.3f}mm {moving} (0x{status_bits:08X})")
# Parse status update data (14 bytes: chan_ident + position + velocity + motor_current + status_bits)
if len(msg.data) >= 14:
position_counts = struct.unpack('<i', msg.data[2:6])[0]
position_mm = position_counts / self.ENCODER_COUNTS_PER_MM
status_bits = struct.unpack('<I', msg.data[10:14])[0]
with self._position_lock:
self._encoder_counts[msg.source] = position_counts
self._stage_positions[msg.source] = position_mm
with self._status_bits_lock:
self._status_bits[msg.source] = status_bits
# Send ACK for every status update
if self._auto_ack_enabled:
ack_msg = self._build_short_message(
MsgId.MOT_ACK_USTATUSUPDATE,
param1=0x01, # chan_ident
param2=0x00,
dest=0x11, # Generic USB destination
source=0x01
)
self._tx_queue.put(ack_msg)
# MOT_MOVE_HOMED is a short message - positions auto-reset to 0 # MOT_MOVE_HOMED is a short message - positions auto-reset to 0
if msg.msg_id == MsgId.MOT_MOVE_HOMED: if msg.msg_id == MsgId.MOT_MOVE_HOMED:
with self._position_lock: with self._position_lock:
@@ -860,6 +964,58 @@ class MotionController:
with self._position_lock: with self._position_lock:
return self._stage_positions.get(self.DEST_Y_AXIS) return self._stage_positions.get(self.DEST_Y_AXIS)
def poll_status(self) -> None:
"""Request status updates from all axes (non-blocking)."""
# Request status from both axes
self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00,
dest=self.DEST_X_AXIS, source=0x01)
self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00,
dest=self.DEST_Y_AXIS, source=0x01)
def request_status_update(self, dest: int) -> None:
"""Request status update from specific axis (non-blocking)."""
self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00,
dest=dest, source=0x01)
def check_for_errors(self) -> Optional[str]:
"""Check if any axis has error flags set and store as last error."""
error_msg = None
with self._status_bits_lock:
x_bits = self._status_bits.get(self.DEST_X_AXIS, 0)
y_bits = self._status_bits.get(self.DEST_Y_AXIS, 0)
if x_bits & MotorStatusBits.ERROR:
error_msg = "X-axis error detected"
elif y_bits & MotorStatusBits.ERROR:
error_msg = "Y-axis error detected"
if error_msg:
with self._last_error_lock:
self._last_error = error_msg
return error_msg
def clear_last_error(self) -> None:
"""Clear the stored last error message."""
with self._last_error_lock:
self._last_error = None
@property
def last_error(self) -> Optional[str]:
"""Get the last error message that was detected."""
with self._last_error_lock:
return self._last_error
def is_move_pending(self) -> bool:
"""Check if any moves are pending."""
with self._pending_moves_lock:
return len(self._pending_moves) > 0
def get_pending_targets(self) -> dict:
"""Get pending move targets as dict mapping dest -> target_position."""
with self._pending_moves_lock:
return dict(self._pending_moves)
# Velocity parameter properties # Velocity parameter properties
@property @property
@@ -1289,12 +1445,23 @@ class MotionController:
Once started, the controller will periodically send status update messages Once started, the controller will periodically send status update messages
containing position, velocity, and status information. These can be captured containing position, velocity, and status information. These can be captured
by registering a callback for the update message type. by registering a callback for the update message type.
Note: On BBD202/BBD203, HW_START_UPDATEMSGS must be sent to each axis
channel (0x21, 0x22) rather than the generic destination (0x11).
""" """
# Send to both axis channels to enable updates from each
self.send_command( self.send_command(
MsgId.HW_START_UPDATEMSGS, MsgId.HW_START_UPDATEMSGS,
param1=0x00, param1=0x00,
param2=0x00, param2=0x00,
dest=0x11, # Generic destination dest=self.DEST_X_AXIS, # 0x21
source=0x01
)
self.send_command(
MsgId.HW_START_UPDATEMSGS,
param1=0x00,
param2=0x00,
dest=self.DEST_Y_AXIS, # 0x22
source=0x01 source=0x01
) )
print("Started automatic status update messages") print("Started automatic status update messages")
@@ -1315,83 +1482,42 @@ class MotionController:
) )
print("Stopped automatic status update messages") print("Stopped automatic status update messages")
def _ack_loop(self) -> None:
"""
Background thread that sends periodic ACK messages.
CRITICAL: The controller will stop responding after ~50 commands if ACK
messages are not sent at least once per second.
"""
while self._ack_running:
try:
self.send_command(
MsgId.MOT_ACK_USTATUSUPDATE,
param1=0x00,
param2=0x00,
dest=0x11, # Generic destination
source=0x01
)
# Sleep for 1 second before next ACK
time.sleep(1.0)
except Exception as e:
if self._ack_running:
print(f"ACK loop error: {e}")
time.sleep(1.0)
def start_status_ack(self) -> None: def start_status_ack(self) -> None:
""" """
Start the periodic status ACK thread. Enable automatic ACK mode.
CRITICAL: This MUST be called to prevent the controller from stopping When enabled, ACKs are sent automatically in the RX loop whenever
responses after ~50 commands. The ACK is sent every 1 second. a status update message is received from the controller.
Use this after scanning operations that require manual ACK mode.
""" """
if self._ack_running: self._auto_ack_enabled = True
return # Already running
self._ack_running = True
self._ack_thread = threading.Thread(target=self._ack_loop, daemon=True)
self._ack_thread.start()
print("Started periodic status ACK (every 1 second)")
def stop_status_ack(self) -> None: def stop_status_ack(self) -> None:
"""Stop the periodic status ACK thread."""
if self._ack_running:
self._ack_running = False
if self._ack_thread:
self._ack_thread.join(timeout=2.0)
self._ack_thread = None
print("Stopped periodic status ACK")
def request_status_update(self, dest: int) -> None:
""" """
Request a status update from the specified axis. Disable automatic ACK mode (switch to manual ACK mode).
This sends MGMSG_MOT_REQ_USTATUSUPDATE which causes the controller to When disabled, ACKs are NOT sent automatically. You must call
respond with MGMSG_MOT_GET_USTATUSUPDATE (0x0491). ack_status_update() manually after each move completes.
Args: Use this during scanning operations for more precise control over
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis) when ACKs are sent. The snake test pattern is:
mc.stop_status_ack()
Raises: for each move:
ValueError: If dest is invalid mc.move_to_fast(...)
while not mc.poll_until_idle(...):
...
mc.ack_status_update() # Manual ACK after move completes
mc.start_status_ack()
""" """
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS): self._auto_ack_enabled = False
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
self.send_command(
MsgId.MOT_REQ_USTATUSUPDATE,
param1=0x01,
param2=0x00,
dest=dest,
source=0x01
)
def ack_status_update(self) -> None: def ack_status_update(self) -> None:
""" """
Send a single status update ACK. Send a single status update ACK manually.
Normally you should use start_status_ack() to run the ACK automatically. Note: ACKs are now sent automatically in response to status update messages.
This method is for manual ACK control if needed. This method is retained for debugging or manual control if needed.
""" """
self.send_command( self.send_command(
MsgId.MOT_ACK_USTATUSUPDATE, MsgId.MOT_ACK_USTATUSUPDATE,
@@ -1821,6 +1947,38 @@ class MotionController:
if waiter_key in self._waiters: if waiter_key in self._waiters:
del self._waiters[waiter_key] del self._waiters[waiter_key]
# Trigger configuration methods
def set_trigger(self, dest: int, trigger_mode: int, polarity: int = 0x01) -> None:
"""
Configure trigger output for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
trigger_mode: Trigger mode from TriggerMode enum
polarity: Trigger polarity (0x01 = active high, 0x02 = active low)
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Build 14-byte trigger configuration packet
data = struct.pack('<HBBBBBBBBBBBB',
0x0001, # Channel ID (bytes 6-7)
trigger_mode, # Trigger mode (byte 8)
polarity, # Polarity (byte 9)
0, 0, 0, 0, 0, 0, 0, 0, 0, 0 # Reserved bytes 10-19
)
self.send_command(
MsgId.MOT_SET_TRIGGER,
data=data,
dest=dest,
source=0x01
)
# Move relative parameter methods # Move relative parameter methods
def set_move_rel_params(self, dest: int, relative_distance: float) -> None: def set_move_rel_params(self, dest: int, relative_distance: float) -> None:
@@ -2259,6 +2417,294 @@ class MotionController:
self.stop_move(self.DEST_Y_AXIS, stop_mode, False) self.stop_move(self.DEST_Y_AXIS, stop_mode, False)
return {'x': None, 'y': None} return {'x': None, 'y': None}
# High-level motion control methods
def poll_positions(self) -> None:
"""
Request position updates for both X and Y axes.
This actively queries the controller for the current positions of both axes.
The positions are stored internally and can be accessed via position_x and position_y properties.
"""
try:
self.get_position(self.DEST_X_AXIS, timeout=1.5)
except Exception as e:
print(f"Error polling X position: {e}")
try:
self.get_position(self.DEST_Y_AXIS, timeout=1.5)
except Exception as e:
print(f"Error polling Y position: {e}")
def move_to_fast(self, x: Optional[float] = None, y: Optional[float] = None) -> None:
"""
Move to absolute position(s) using fast non-blocking moves.
Args:
x: Target X position in mm (None to leave X unchanged)
y: Target Y position in mm (None to leave Y unchanged)
"""
# Track pending moves
with self._pending_moves_lock:
if x is not None:
self._pending_moves[self.DEST_X_AXIS] = x
if y is not None:
self._pending_moves[self.DEST_Y_AXIS] = y
if y is not None:
y_counts = int(y * self.ENCODER_COUNTS_PER_MM)
y_data = struct.pack('<Hi', 0x0001, y_counts)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
data=y_data,
dest=self.DEST_Y_AXIS,
source=0x01
)
# Delay between commands - controller may need time to process
if x is not None and y is not None:
time.sleep(0.100) # 100ms delay
if x is not None:
x_counts = int(x * self.ENCODER_COUNTS_PER_MM)
x_data = struct.pack('<Hi', 0x0001, x_counts)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
data=x_data,
dest=self.DEST_X_AXIS,
source=0x01
)
def move_and_wait_for_completed(self, x: float = None, y: float = None,
timeout: float = 30.0) -> bool:
"""
Move to position and wait for MOT_MOVE_COMPLETED message(s).
This method sends absolute move commands and polls for MOT_MOVE_COMPLETED
messages by checking the _move_completed dictionary which is updated by
the RX thread when completion messages arrive.
Args:
x: Target X position in mm, or None to skip X axis
y: Target Y position in mm, or None to skip Y axis
timeout: Maximum wait time in seconds (default 30s)
Returns:
True if all moves completed successfully, False if timeout
"""
if x is None and y is None:
return True # Nothing to do
# Determine which axes we're moving
axes_to_move = []
if x is not None:
axes_to_move.append(self.DEST_X_AXIS)
if y is not None:
axes_to_move.append(self.DEST_Y_AXIS)
# Clear any previous completion data for axes we're about to move
with self._move_completed_lock:
for dest in axes_to_move:
if dest in self._move_completed:
del self._move_completed[dest]
# Track pending moves
with self._pending_moves_lock:
if x is not None:
self._pending_moves[self.DEST_X_AXIS] = x
if y is not None:
self._pending_moves[self.DEST_Y_AXIS] = y
move_type = "X+Y" if (x is not None and y is not None) else "X-only" if x is not None else "Y-only"
print(f" [move_and_wait] {move_type}: x={x}, y={y}")
# Send Y move first if needed
if y is not None:
y_counts = int(y * self.ENCODER_COUNTS_PER_MM)
y_data = struct.pack('<Hi', 0x0001, y_counts)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
data=y_data,
dest=self.DEST_Y_AXIS,
source=0x01
)
# Small delay between commands if moving both axes
if x is not None and y is not None:
time.sleep(0.020)
# Send X move if needed
if x is not None:
x_counts = int(x * self.ENCODER_COUNTS_PER_MM)
x_data = struct.pack('<Hi', 0x0001, x_counts)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
data=x_data,
dest=self.DEST_X_AXIS,
source=0x01
)
# Poll for MOT_MOVE_COMPLETED messages
start_time = time.time()
completed_axes = set()
while len(completed_axes) < len(axes_to_move):
elapsed = time.time() - start_time
if elapsed > timeout:
missing = [("X" if d == self.DEST_X_AXIS else "Y") for d in axes_to_move if d not in completed_axes]
print(f" [move_and_wait] Timeout waiting for {', '.join(missing)} MOT_MOVE_COMPLETED")
return False
# Check for completions
with self._move_completed_lock:
for dest in axes_to_move:
if dest not in completed_axes and dest in self._move_completed:
axis_name = "X" if dest == self.DEST_X_AXIS else "Y"
print(f" [move_and_wait] {axis_name}-axis move completed")
completed_axes.add(dest)
# Clear pending move
with self._pending_moves_lock:
if dest in self._pending_moves:
del self._pending_moves[dest]
if len(completed_axes) < len(axes_to_move):
time.sleep(0.010) # 10ms poll interval
return True
def poll_until_idle(self, tolerance: float = 0.005, timeout: float = 0.1) -> bool:
"""
Poll positions and check if all pending moves are complete within tolerance.
This method checks if the stage has settled at the target positions for all pending moves.
A move is considered complete when the current position is within 'tolerance' of the target.
Args:
tolerance: Position tolerance in mm (default 0.005mm = 5 microns)
timeout: Time to spend polling in this call (default 0.1s)
Returns:
True if all pending moves are complete (within tolerance), False otherwise
"""
# Get current pending targets (snapshot)
with self._pending_moves_lock:
pending = dict(self._pending_moves)
if not pending:
# No pending moves
return True
# Check if all axes are within tolerance
all_settled = True
completed_axes = []
# Query positions using get_position which properly waits for responses
# Use short timeout to avoid blocking too long
query_timeout = min(timeout, 0.5)
for dest, target in pending.items():
try:
# Get current position with timeout
current = self.get_position(dest, timeout=query_timeout)
if current is None:
# Position query failed or timed out - not settled
all_settled = False
continue
error = abs(current - target)
if error <= tolerance:
# This axis is settled
completed_axes.append(dest)
else:
# Still moving
all_settled = False
except Exception as e:
# Position query failed - assume not settled
print(f"Error querying position for axis 0x{dest:02X}: {e}")
all_settled = False
# Remove completed axes from pending list
if completed_axes:
with self._pending_moves_lock:
for dest in completed_axes:
if dest in self._pending_moves:
del self._pending_moves[dest]
return all_settled
def poll_until_idle_passive(self, tolerance: float = 0.005, debug: bool = False) -> bool:
"""
Check if all pending moves are complete using cached positions from automatic updates.
Unlike poll_until_idle(), this does NOT send position request commands. It only
reads the cached positions that are updated from automatic status updates
(MOT_GET_USTATUSUPDATE). This avoids command traffic that might interfere with
move execution.
Args:
tolerance: Position tolerance in mm (default 0.005mm = 5 microns)
debug: If True, print debug info about positions
Returns:
True if all pending moves are complete (within tolerance), False otherwise
"""
# Get current pending targets (snapshot)
with self._pending_moves_lock:
pending = dict(self._pending_moves)
if not pending:
return True
# Check if all axes are within tolerance using cached positions
all_settled = True
completed_axes = []
for dest, target in pending.items():
# Get cached position (updated by automatic status updates)
with self._position_lock:
current = self._stage_positions.get(dest)
axis_name = "X" if dest == self.DEST_X_AXIS else "Y"
if current is None:
# No cached position yet - not settled
if debug:
print(f" [poll] {axis_name}: no cached position")
all_settled = False
continue
error = abs(current - target)
if debug:
print(f" [poll] {axis_name}: current={current:.3f}, target={target:.3f}, error={error*1000:.1f}um")
if error <= tolerance:
completed_axes.append(dest)
else:
all_settled = False
# Remove completed axes from pending list
if completed_axes:
with self._pending_moves_lock:
for dest in completed_axes:
if dest in self._pending_moves:
del self._pending_moves[dest]
return all_settled
def clear_pending_moves(self) -> None:
"""
Clear all pending move targets.
This clears the internal tracking of pending moves. It does NOT stop the motors -
use stop_all_axes() if you want to halt motion.
"""
with self._pending_moves_lock:
self._pending_moves.clear()
# Status bit decoding methods # Status bit decoding methods
@staticmethod @staticmethod
+45
View File
@@ -0,0 +1,45 @@
"""
Coherent HOPS Laser Driver - Stub Module
This is a temporary stub to allow testing camera integration.
"""
class CoherentHOPSLaser:
"""Stub class for Coherent HOPS Laser"""
pass
class DummyLaser:
"""Dummy laser for testing without hardware"""
def connect(self):
"""Simulate connection"""
pass
def disconnect(self):
"""Simulate disconnection"""
pass
def get_hardware_id(self):
"""Return simulated hardware ID"""
return "SIM-12345"
def get_laser_model(self):
"""Return simulated model"""
return "Genesis Simulator"
def get_interlock_status(self):
"""Return simulated interlock status"""
return "OK"
def get_key_switch_status(self):
"""Return simulated key switch status"""
return "ON"
def get_temperature_main(self):
"""Return simulated main temperature"""
return 25.5
def get_temperature_eta(self):
"""Return simulated ETA temperature"""
return 26.3
+669
View File
@@ -0,0 +1,669 @@
"""
Genesis SLM MX 532 Laser Core Hardware Control Module
======================================================
This module provides low-level hardware control for the Genesis SLM MX 532 laser
using NXP I2C-over-serial protocol. It contains reusable classes for serial
communication, I2C protocol handling, device control, and laser operations.
This module is GUI-independent and can be used by both command-line and GUI applications.
Protocol Overview:
-----------------
The laser uses NXP I2C-over-serial protocol with packet format:
[0x53] [I2C_ADDR] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50]
For reads:
[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50]
I2C Devices:
-----------
- X9119 digital potentiometer at 0x52 - laser current control
- PCA9555 I/O expander at 0x4a - digital I/O
- AD5254 digital potentiometer at 0x58 - limits control
- ADS7828 ADC at 0x90 - sensor readings
- M24C64 EEPROM at 0xa4 - configuration storage
Author: Claude
Date: 2026-01-24
"""
import time
from datetime import datetime
from typing import Optional, List
from enum import IntEnum
import serial
from serial.tools import list_ports
# ============================================================================
# Constants - I2C Addresses
# ============================================================================
class I2CAddress(IntEnum):
"""I2C device addresses (7-bit addresses shifted left by 1)"""
# X9119 Digital Potentiometer (current control)
X9119_CURRENT_WRITE = 0x52
X9119_CURRENT_READ = 0x53
# PCA9555 I/O Expanders
PCA9555_DIO_WRITE = 0x4a # Main control I/O at 0x14a
PCA9555_DIO_READ = 0x4b
PCA9555_PSGLUE_WRITE = 0x48 # Power supply glue at 0x148
PCA9555_PSGLUE_READ = 0x49
PCA9555_HEAD_WRITE = 0x44 # Head DIO at 0x144
PCA9555_HEAD_READ = 0x45
PCA9555_LDD_WRITE = 0x40 # LDD control at 0x140
PCA9555_LDD_READ = 0x41
# AD5254 Digital Potentiometer (limits)
AD5254_WRITE = 0x58
# ADS7828 ADC (sensor readings)
ADS7828_WRITE = 0x90
ADS7828_READ = 0x91
# M24C64 EEPROM (configuration)
EEPROM_WRITE = 0xa4
EEPROM_READ = 0xa5
# ============================================================================
# Constants - PCA9555 Register Addresses
# ============================================================================
class PCA9555Register(IntEnum):
"""PCA9555 I/O expander register addresses"""
INPUT_PORT_0 = 0x00
INPUT_PORT_1 = 0x01
OUTPUT_PORT_0 = 0x02
OUTPUT_PORT_1 = 0x03
POLARITY_INV_0 = 0x04
POLARITY_INV_1 = 0x05
CONFIG_PORT_0 = 0x06
CONFIG_PORT_1 = 0x07
# ============================================================================
# Constants - Control Bitmasks
# ============================================================================
class ControlBitmask(IntEnum):
"""Bitmasks for PCA9555 port 0 control bits"""
SHUTTER = 0x01 # Bit 0
CURRENT_MODE = 0x04 # Bit 2
REMOTE_ENABLE = 0x08 # Bit 3
ANALOG_ENABLE = 0x10 # Bit 4
KEYSWITCH = 0x20 # Bit 5
INTERLOCK = 0x01 # Bit 0 (on different PCA9555)
LDD_ENABLE = 0x01 # Bit 0 (on LDD PCA9555)
# ============================================================================
# Serial Communication Layer
# ============================================================================
class SerialComm:
"""Handles low-level serial port communication"""
def __init__(self):
self.port: Optional[serial.Serial] = None
self.port_name: str = "/dev/ttyUSB0"
self.baudrate: int = 9600
self.timeout: float = 1.0
self.packet_log: List[str] = []
def connect(self, port_name: str, baudrate: int = 9600) -> bool:
"""
Connect to serial port
Args:
port_name: Serial port device name
baudrate: Baud rate (default 9600)
Returns:
True if successful, False otherwise
"""
try:
self.port_name = port_name
self.baudrate = baudrate
self.port = serial.Serial(
port=port_name,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout
)
time.sleep(0.1) # Allow port to stabilize
return True
except Exception as e:
print(f"Serial connection error: {e}")
return False
def disconnect(self):
"""Disconnect from serial port"""
if self.port and self.port.is_open:
self.port.close()
self.port = None
def is_connected(self) -> bool:
"""Check if serial port is connected"""
return self.port is not None and self.port.is_open
def write(self, data: bytes) -> bool:
"""
Write data to serial port
Args:
data: Bytes to write
Returns:
True if successful, False otherwise
"""
if not self.is_connected():
return False
try:
self.port.write(data)
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
hex_str = " ".join(f"{b:02x}" for b in data)
self.packet_log.append(f"[{timestamp}] TX: {hex_str}")
return True
except Exception as e:
print(f"Serial write error: {e}")
return False
def read(self, size: int) -> Optional[bytes]:
"""
Read data from serial port
Args:
size: Number of bytes to read
Returns:
Bytes read or None on error
"""
if not self.is_connected():
return None
try:
data = self.port.read(size)
if data:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
hex_str = " ".join(f"{b:02x}" for b in data)
self.packet_log.append(f"[{timestamp}] RX: {hex_str}")
return data
except Exception as e:
print(f"Serial read error: {e}")
return None
def flush(self):
"""Flush serial port buffers"""
if self.is_connected():
self.port.reset_input_buffer()
self.port.reset_output_buffer()
def get_packet_log(self, last_n: int = 100) -> List[str]:
"""Get last N entries from packet log"""
return self.packet_log[-last_n:]
def clear_packet_log(self):
"""Clear packet log"""
self.packet_log.clear()
# ============================================================================
# I2C Protocol Layer
# ============================================================================
class I2CProtocol:
"""Handles NXP I2C-over-serial protocol packet construction"""
NXP_START = 0x53
NXP_STOP = 0x50
def __init__(self, serial_comm: SerialComm):
self.serial = serial_comm
def write(self, i2c_addr_write: int, cmd: bytes, data: bytes = b'') -> bool:
"""
Build and send NXP I2C write packet
Packet format: [0x53] [I2C_ADDR] [LENGTH] [COMMAND] [DATA] [0x50]
Args:
i2c_addr_write: I2C write address
cmd: Command bytes
data: Data bytes (optional)
Returns:
True if successful, False otherwise
"""
if not isinstance(cmd, bytes):
cmd = bytes([cmd])
if not isinstance(data, bytes):
data = bytes(data)
length = len(cmd) + len(data)
packet = bytes([self.NXP_START, i2c_addr_write, length]) + cmd + data + bytes([self.NXP_STOP])
return self.serial.write(packet)
def read(self, i2c_addr_write: int, cmd: bytes, cmd_len: int, data_len: int) -> Optional[bytes]:
"""
Build and send NXP I2C read packet, return data
Packet format for read:
[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50]
Args:
i2c_addr_write: I2C write address
cmd: Command bytes
cmd_len: Length of command
data_len: Expected data length to read
Returns:
Data bytes read or None on error
"""
if not isinstance(cmd, bytes):
cmd = bytes([cmd])
i2c_addr_read = i2c_addr_write | 0x01 # Set read bit
# First part: write command
packet_write = bytes([self.NXP_START, i2c_addr_write, cmd_len]) + cmd
# Second part: read data
packet_read = bytes([self.NXP_START, i2c_addr_read, data_len, self.NXP_STOP])
packet = packet_write + packet_read
if not self.serial.write(packet):
return None
# Read response
# Expected response: [data_bytes]
time.sleep(0.05) # Give device time to respond
response = self.serial.read(data_len)
return response if response and len(response) == data_len else None
# ============================================================================
# I2C Device Functions
# ============================================================================
class I2CDevices:
"""High-level I2C device control functions"""
def __init__(self, protocol: I2CProtocol):
self.protocol = protocol
# ------------------------------------------------------------------------
# PCA9555 I/O Expander Functions
# ------------------------------------------------------------------------
def pca9555_read_port(self, addr_write: int, port: int) -> Optional[int]:
"""
Read PCA9555 port register
Args:
addr_write: I2C write address
port: Register address (0-7)
Returns:
Port value (0-255) or None on error
"""
data = self.protocol.read(addr_write, bytes([port]), 1, 1)
return data[0] if data else None
def pca9555_write_port(self, addr_write: int, port: int, value: int) -> bool:
"""
Write PCA9555 port register
Args:
addr_write: I2C write address
port: Register address (0-7)
value: Value to write (0-255)
Returns:
True if successful, False otherwise
"""
return self.protocol.write(addr_write, bytes([port]), bytes([value]))
def pca9555_set_bit(self, addr_write: int, port: int, bitmask: int, state: bool) -> bool:
"""
Set or clear specific bit on PCA9555
Args:
addr_write: I2C write address
port: Register address
bitmask: Bit mask (e.g., 0x01 for bit 0)
state: True to set bit, False to clear
Returns:
True if successful, False otherwise
"""
# Read current value
current = self.pca9555_read_port(addr_write, port)
if current is None:
return False
# Modify bit
if state:
new_value = current | bitmask
else:
new_value = current & ~bitmask
# Write back
return self.pca9555_write_port(addr_write, port, new_value)
# ------------------------------------------------------------------------
# X9119 Digital Potentiometer Functions
# ------------------------------------------------------------------------
def x9119_write_wiper(self, addr_write: int, value: int) -> bool:
"""
Set X9119 wiper position
Args:
addr_write: I2C write address
value: Wiper position (0-1023, 10-bit)
Returns:
True if successful, False otherwise
"""
# X9119 write wiper command: 0xa0 followed by 2 bytes (10-bit value)
# Value is split into two bytes: MSB contains upper 2 bits, LSB contains lower 8 bits
value = max(0, min(1023, value)) # Clamp to valid range
msb = (value >> 8) & 0x03 # Upper 2 bits
lsb = value & 0xFF # Lower 8 bits
cmd = bytes([0xa0])
data = bytes([msb, lsb])
return self.protocol.write(addr_write, cmd, data)
# ------------------------------------------------------------------------
# ADS7828 ADC Functions
# ------------------------------------------------------------------------
def ads7828_read(self, addr_write: int, channel: int) -> Optional[int]:
"""
Read ADS7828 ADC channel
Args:
addr_write: I2C write address
channel: Channel to read (0-7)
Returns:
12-bit ADC value (0-4095) or None on error
"""
# ADS7828 command byte format:
# Bit 7: SD (single-ended=1)
# Bits 6-4: Channel select
# Bit 3: PD1 (power-down mode)
# Bit 2: PD0 (power-down mode)
# Bits 1-0: Don't care
# For single-ended read with internal reference on
cmd_byte = 0x80 | ((channel & 0x07) << 4) | 0x0c
data = self.protocol.read(addr_write, bytes([cmd_byte]), 1, 2)
if not data or len(data) != 2:
return None
# Combine two bytes into 12-bit value
value = (data[0] << 8) | data[1]
value = (value >> 4) & 0x0FFF # Extract 12-bit value
return value
# ============================================================================
# Laser Control Layer
# ============================================================================
class LaserControl:
"""High-level laser control functions"""
def __init__(self, devices: I2CDevices):
self.devices = devices
self._safe_state_active = False
# ------------------------------------------------------------------------
# SET Commands
# ------------------------------------------------------------------------
def set_current(self, value: int) -> bool:
"""
Set laser current command (CCMD=)
Args:
value: Current value (0-1023)
Returns:
True if successful, False otherwise
"""
return self.devices.x9119_write_wiper(I2CAddress.X9119_CURRENT_WRITE, value)
def set_power_cmd(self, value: int) -> bool:
"""
Set power command (PCMD=)
Note: Uses same device as current control, different channel
Args:
value: Power value (0-1023)
Returns:
True if successful, False otherwise
"""
return self.devices.x9119_write_wiper(I2CAddress.X9119_CURRENT_WRITE, value)
def set_shutter(self, state: bool) -> bool:
"""
Set shutter state (SHCMD=)
Args:
state: True for open, False for closed
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.SHUTTER,
state
)
def set_keyswitch(self, state: bool) -> bool:
"""
Set keyswitch state (KSWCMD=)
Args:
state: True for on, False for off
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.KEYSWITCH,
state
)
def set_current_mode(self, state: bool) -> bool:
"""
Set current mode (CMODECMD=)
Args:
state: True for on, False for off
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.CURRENT_MODE,
state
)
def set_analog_enable(self, state: bool) -> bool:
"""
Set analog input enable (ANACMD=)
Args:
state: True for enabled, False for disabled
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.ANALOG_ENABLE,
state
)
def set_remote_enable(self, state: bool) -> bool:
"""
Set remote enable (REM=)
Args:
state: True for enabled, False for disabled
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.REMOTE_ENABLE,
state
)
# ------------------------------------------------------------------------
# GET/Query Commands
# ------------------------------------------------------------------------
def get_current_actual(self) -> Optional[float]:
"""
Get actual current reading
Returns:
Current in arbitrary units (0-4095) or None on error
"""
# Read from ADS7828, channel based on command 0x84
# Command 0x84 suggests channel 0
value = self.devices.ads7828_read(I2CAddress.ADS7828_WRITE, 0)
return value if value is not None else None
def get_interlock_status(self) -> Optional[bool]:
"""
Get interlock status
Returns:
True if interlock OK, False if fault, None on error
"""
value = self.devices.pca9555_read_port(
I2CAddress.PCA9555_PSGLUE_WRITE,
PCA9555Register.INPUT_PORT_0
)
if value is None:
return None
# Bit 0 indicates interlock status
return bool(value & ControlBitmask.INTERLOCK)
def get_ldd_enable_status(self) -> Optional[bool]:
"""
Get laser diode driver enable status
Returns:
True if enabled, False if disabled, None on error
"""
value = self.devices.pca9555_read_port(
I2CAddress.PCA9555_LDD_WRITE,
PCA9555Register.INPUT_PORT_0
)
if value is None:
return None
return bool(value & ControlBitmask.LDD_ENABLE)
def get_psglue_in_status(self) -> Optional[int]:
"""
Get power supply glue input status
Returns:
Port value or None on error
"""
return self.devices.pca9555_read_port(
I2CAddress.PCA9555_PSGLUE_WRITE,
PCA9555Register.INPUT_PORT_0
)
def get_psglue_out_status(self) -> Optional[int]:
"""
Get power supply glue output status
Returns:
Port value or None on error
"""
return self.devices.pca9555_read_port(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0
)
def get_head_dio_status(self) -> Optional[int]:
"""
Get head DIO status
Returns:
Port value or None on error
"""
return self.devices.pca9555_read_port(
I2CAddress.PCA9555_HEAD_WRITE,
PCA9555Register.INPUT_PORT_0
)
# ------------------------------------------------------------------------
# Safety Functions
# ------------------------------------------------------------------------
def emergency_stop(self) -> bool:
"""
Emergency stop: close shutter, set current to 0, disable keyswitch
Returns:
True if all operations successful, False otherwise
"""
success = True
success &= self.set_shutter(False)
success &= self.set_current(0)
success &= self.set_keyswitch(False)
self._safe_state_active = True
return success
def enter_safe_state(self) -> bool:
"""
Enter safe state (similar to emergency stop but also disables remote)
Returns:
True if successful, False otherwise
"""
success = True
success &= self.set_shutter(False)
success &= self.set_current(0)
success &= self.set_power_cmd(0)
success &= self.set_keyswitch(False)
success &= self.set_remote_enable(False)
self._safe_state_active = True
return success
+296
View File
@@ -0,0 +1,296 @@
"""
Helios Laser System Driver
Basic implementation for controlling the Helios pulsed laser.
"""
import serial
import time
import logging
from typing import Optional, List
from enum import Enum
logger = logging.getLogger(__name__)
class PulseMode(Enum):
"""Helios pulse mode enumeration"""
SINGLE_PULSE = 0
CONTINUOUS_GATING = 1
CONTINUOUS_PULSING = 2
class HeliosLaser:
"""
Driver for Helios pulsed laser system.
Communication: RS-232, 9600 baud, 8N1
Commands are ASCII strings terminated with CR
"""
def __init__(self, port: str = None, timeout: float = 1.0):
"""
Initialize Helios laser driver.
Args:
port: Serial port (e.g., '/dev/ttyUSB0' or 'COM5')
timeout: Serial timeout in seconds
"""
self.port = port
self.timeout = timeout
self.serial = None
self.is_connected = False
@staticmethod
def list_available_ports() -> List[str]:
"""List available serial ports"""
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
return [port.device for port in ports]
def connect(self, port: str = None) -> bool:
"""
Connect to the Helios laser.
Args:
port: Serial port (uses stored port if None)
Returns:
True if connection successful
"""
if port:
self.port = port
if not self.port:
logger.error("No port specified")
return False
try:
self.serial = serial.Serial(
port=self.port,
baudrate=9600,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout
)
time.sleep(0.1) # Allow time for connection to stabilize
self.is_connected = True
logger.info(f"Connected to Helios laser on {self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect to Helios laser: {e}")
self.is_connected = False
return False
def disconnect(self):
"""Disconnect from the laser"""
if self.serial and self.serial.is_open:
try:
# Disable laser before disconnecting
self.set_laser_enable(False)
self.serial.close()
logger.info("Disconnected from Helios laser")
except Exception as e:
logger.error(f"Error during disconnect: {e}")
self.is_connected = False
self.serial = None
def _send_command(self, command: str) -> bool:
"""
Send a command to the laser.
Args:
command: ASCII command string (without CR)
Returns:
True if sent successfully
"""
if not self.is_connected or not self.serial:
logger.error("Not connected to laser")
return False
try:
cmd_bytes = (command + '\r').encode('ascii')
self.serial.write(cmd_bytes)
logger.debug(f"Sent command: {command}")
return True
except Exception as e:
logger.error(f"Failed to send command '{command}': {e}")
return False
def _query(self, command: str) -> Optional[str]:
"""
Send a query and read response.
Args:
command: ASCII query command (without CR)
Returns:
Response string or None if error
"""
if not self._send_command(command):
return None
try:
response = self.serial.readline().decode('ascii').strip()
logger.debug(f"Query '{command}' response: {response}")
return response
except Exception as e:
logger.error(f"Failed to read response for '{command}': {e}")
return None
def set_frequency_hz(self, frequency: int) -> bool:
"""
Set laser pulse frequency in Hz.
Args:
frequency: Frequency in Hz (16700 - 125000)
Returns:
True if successful
"""
if not (16700 <= frequency <= 125000):
logger.error(f"Frequency {frequency} Hz out of range (16700-125000)")
return False
# Convert frequency to period in nanoseconds
period_ns = int(1e9 / frequency)
command = f"FP={period_ns}"
return self._send_command(command)
def set_current_ma(self, current: int) -> bool:
"""
Set pump diode current in mA.
Args:
current: Current in mA (0 - 7000)
Returns:
True if successful
"""
if not (0 <= current <= 7000):
logger.error(f"Current {current} mA out of range (0-7000)")
return False
command = f"PC={current}"
return self._send_command(command)
def set_pulse_mode(self, mode: PulseMode) -> bool:
"""
Set pulse mode.
Args:
mode: PulseMode enumeration value
Returns:
True if successful
"""
command = f"PM={mode.value}"
return self._send_command(command)
def set_laser_enable(self, enable: bool) -> bool:
"""
Enable or disable laser emission.
Args:
enable: True to enable, False to disable
Returns:
True if successful
"""
command = f"LE={1 if enable else 0}"
success = self._send_command(command)
if success:
state = "enabled" if enable else "disabled"
logger.info(f"Laser {state}")
return success
def is_laser_enabled(self) -> bool:
"""
Check if laser is currently enabled.
Returns:
True if laser is enabled
"""
response = self._query("LE?")
if response:
try:
return int(response) == 1
except ValueError:
logger.error(f"Invalid response for LE?: {response}")
return False
def get_frequency_hz(self) -> Optional[int]:
"""
Get current laser frequency in Hz.
Returns:
Frequency in Hz or None if error
"""
response = self._query("FP?")
if response:
try:
period_ns = int(response)
return int(1e9 / period_ns)
except (ValueError, ZeroDivisionError):
logger.error(f"Invalid response for FP?: {response}")
return None
def get_current_ma(self) -> Optional[int]:
"""
Get current pump diode current in mA.
Returns:
Current in mA or None if error
"""
response = self._query("PC?")
if response:
try:
return int(response)
except ValueError:
logger.error(f"Invalid response for PC?: {response}")
return None
def get_power_mw(self) -> Optional[float]:
"""
Get laser output power in mW.
Returns:
Power in mW or None if error
"""
response = self._query("PO?")
if response:
try:
return float(response)
except ValueError:
logger.error(f"Invalid response for PO?: {response}")
return None
def get_controller_serial(self) -> Optional[str]:
"""
Get controller serial number.
Returns:
Serial number string or None if error
"""
return self._query("SN?")
def get_head_serial(self) -> Optional[str]:
"""
Get laser head serial number.
Returns:
Serial number string or None if error
"""
return self._query("HSN?")
def __del__(self):
"""Destructor - ensure cleanup"""
self.disconnect()
+475
View File
@@ -0,0 +1,475 @@
"""
uC480 Camera Driver
Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library.
Provides camera control, live streaming, and image capture capabilities.
"""
import numpy as np
from pyueye import ueye
from PyQt6.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtGui import QImage
import logging
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
class UC480Camera(QObject):
"""
Driver class for uC480 camera.
Handles initialization, configuration, and image acquisition.
"""
# Signals
frame_ready = pyqtSignal(QImage) # Emitted when a new frame is captured
error_occurred = pyqtSignal(str) # Emitted when an error occurs
def __init__(self, camera_id: int = 0):
"""
Initialize the uC480 camera driver.
Args:
camera_id: Camera ID (0 for first available camera)
"""
super().__init__()
self.camera_id = camera_id
self.h_cam = ueye.HIDS(camera_id)
self.is_initialized = False
self.is_capturing = False
# Memory and image info
self.mem_ptr = ueye.c_mem_p()
self.mem_id = ueye.int()
self.pitch = ueye.INT()
# Camera info
self.sensor_info = ueye.SENSORINFO()
self.cam_info = ueye.CAMINFO()
self.rect_aoi = ueye.IS_RECT()
# Image dimensions
self.width = 0
self.height = 0
self.bits_per_pixel = 24 # Default to 24-bit color
self.bytes_per_pixel = 3
self.color_mode = ueye.IS_CM_BGR8_PACKED
def initialize(self) -> bool:
"""
Initialize the camera and allocate memory.
Returns:
True if successful, False otherwise
"""
try:
# Initialize camera
ret = ueye.is_InitCamera(self.h_cam, None)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to initialize camera: {ret}")
self.error_occurred.emit(f"Failed to initialize camera: {ret}")
return False
# Get sensor info
ret = ueye.is_GetSensorInfo(self.h_cam, self.sensor_info)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to get sensor info: {ret}")
self.cleanup()
return False
# Get camera info
ret = ueye.is_GetCameraInfo(self.h_cam, self.cam_info)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to get camera info: {ret}")
self.cleanup()
return False
# Set color mode
ret = ueye.is_SetColorMode(self.h_cam, self.color_mode)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to set color mode: {ret}")
self.cleanup()
return False
# Get maximum image size
self.width = self.sensor_info.nMaxWidth.value
self.height = self.sensor_info.nMaxHeight.value
# Set Area of Interest (AOI) to maximum size
self.rect_aoi.s32X = ueye.int(0)
self.rect_aoi.s32Y = ueye.int(0)
self.rect_aoi.s32Width = ueye.int(self.width)
self.rect_aoi.s32Height = ueye.int(self.height)
ret = ueye.is_AOI(self.h_cam, ueye.IS_AOI_IMAGE_SET_AOI, self.rect_aoi, ueye.sizeof(self.rect_aoi))
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to set AOI: {ret}")
self.cleanup()
return False
# Allocate image memory
ret = ueye.is_AllocImageMem(
self.h_cam,
self.width,
self.height,
self.bits_per_pixel,
self.mem_ptr,
self.mem_id
)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to allocate image memory: {ret}")
self.cleanup()
return False
# Set active memory
ret = ueye.is_SetImageMem(self.h_cam, self.mem_ptr, self.mem_id)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to set active memory: {ret}")
self.cleanup()
return False
# Get pitch (bytes per line)
ret = ueye.is_GetImageMemPitch(self.h_cam, self.pitch)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to get pitch: {ret}")
self.cleanup()
return False
self.is_initialized = True
logger.info(f"Camera initialized: {self.width}x{self.height}, {self.bits_per_pixel}bpp")
# Set default settings
self.set_exposure(10.0) # 10ms default exposure
self.set_pixel_clock(30) # 30MHz default pixel clock
self.set_framerate(30.0) # 30fps default
return True
except Exception as e:
logger.error(f"Exception during camera initialization: {e}")
self.error_occurred.emit(f"Exception during initialization: {e}")
self.cleanup()
return False
def cleanup(self):
"""Release camera resources."""
if self.is_capturing:
self.stop_capture()
if self.mem_ptr:
ueye.is_FreeImageMem(self.h_cam, self.mem_ptr, self.mem_id)
self.mem_ptr = None
if self.is_initialized:
ueye.is_ExitCamera(self.h_cam)
self.is_initialized = False
logger.info("Camera resources released")
def start_capture(self) -> bool:
"""
Start continuous video capture.
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
logger.error("Camera not initialized")
return False
if self.is_capturing:
logger.warning("Camera already capturing")
return True
ret = ueye.is_CaptureVideo(self.h_cam, ueye.IS_DONT_WAIT)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to start capture: {ret}")
self.error_occurred.emit(f"Failed to start capture: {ret}")
return False
self.is_capturing = True
logger.info("Video capture started")
return True
def stop_capture(self) -> bool:
"""
Stop continuous video capture.
Returns:
True if successful, False otherwise
"""
if not self.is_capturing:
return True
ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to stop capture: {ret}")
return False
self.is_capturing = False
logger.info("Video capture stopped")
return True
def get_frame(self) -> Optional[QImage]:
"""
Capture a single frame from the camera.
Returns:
QImage if successful, None otherwise
"""
if not self.is_initialized:
logger.error("Camera not initialized")
return None
# Create numpy array from image memory
try:
array = ueye.get_data(
self.mem_ptr,
self.width,
self.height,
self.bits_per_pixel,
self.pitch,
copy=True
)
# Reshape to image dimensions
frame = np.reshape(array, (self.height, self.width, self.bytes_per_pixel))
# Convert to QImage (BGR to RGB)
height, width, channel = frame.shape
bytes_per_line = self.bytes_per_pixel * width
# Convert BGR to RGB
rgb_frame = frame[:, :, ::-1].copy()
q_image = QImage(
rgb_frame.data,
width,
height,
bytes_per_line,
QImage.Format.Format_RGB888
)
# Make a copy since the numpy array will be deleted
return q_image.copy()
except Exception as e:
logger.error(f"Failed to get frame: {e}")
self.error_occurred.emit(f"Failed to get frame: {e}")
return None
def set_exposure(self, exposure_ms: float) -> bool:
"""
Set camera exposure time.
Args:
exposure_ms: Exposure time in milliseconds
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
return False
exposure = ueye.c_double(exposure_ms)
ret = ueye.is_Exposure(
self.h_cam,
ueye.IS_EXPOSURE_CMD_SET_EXPOSURE,
exposure,
ueye.sizeof(exposure)
)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Exposure set to {exposure_ms}ms")
return True
else:
logger.error(f"Failed to set exposure: {ret}")
return False
def get_exposure(self) -> Optional[float]:
"""
Get current exposure time.
Returns:
Exposure time in milliseconds, or None if failed
"""
if not self.is_initialized:
return None
exposure = ueye.c_double()
ret = ueye.is_Exposure(
self.h_cam,
ueye.IS_EXPOSURE_CMD_GET_EXPOSURE,
exposure,
ueye.sizeof(exposure)
)
if ret == ueye.IS_SUCCESS:
return exposure.value
else:
return None
def set_pixel_clock(self, pixel_clock_mhz: int) -> bool:
"""
Set camera pixel clock.
Args:
pixel_clock_mhz: Pixel clock in MHz
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
return False
ret = ueye.is_PixelClock(
self.h_cam,
ueye.IS_PIXELCLOCK_CMD_SET,
ueye.c_uint(pixel_clock_mhz),
ueye.sizeof(ueye.c_uint)
)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Pixel clock set to {pixel_clock_mhz}MHz")
return True
else:
logger.error(f"Failed to set pixel clock: {ret}")
return False
def set_framerate(self, fps: float) -> bool:
"""
Set camera framerate.
Args:
fps: Frames per second
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
return False
new_fps = ueye.c_double(fps)
actual_fps = ueye.c_double()
ret = ueye.is_SetFrameRate(self.h_cam, new_fps, actual_fps)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Framerate set to {fps}fps")
return True
else:
logger.error(f"Failed to set framerate: {ret}")
return False
def get_framerate(self) -> Optional[float]:
"""
Get current framerate.
Returns:
Framerate in fps, or None if failed
"""
if not self.is_initialized:
return None
fps = ueye.c_double()
ret = ueye.is_GetFramesPerSecond(self.h_cam, fps)
if ret == ueye.IS_SUCCESS:
return fps.value
else:
return None
def set_gain(self, master_gain: int) -> bool:
"""
Set camera master gain.
Args:
master_gain: Gain value (0-100)
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
return False
if master_gain < 0 or master_gain > 100:
logger.error(f"Gain value {master_gain} out of range (0-100)")
return False
ret = ueye.is_SetHardwareGain(
self.h_cam,
master_gain,
ueye.IS_IGNORE_PARAMETER,
ueye.IS_IGNORE_PARAMETER,
ueye.IS_IGNORE_PARAMETER
)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Master gain set to {master_gain}")
return True
else:
logger.error(f"Failed to set gain: {ret}")
return False
def get_sensor_info(self) -> dict:
"""
Get camera sensor information.
Returns:
Dictionary with sensor information
"""
if not self.is_initialized:
return {}
return {
'sensor_name': self.sensor_info.strSensorName.decode('utf-8'),
'max_width': self.sensor_info.nMaxWidth.value,
'max_height': self.sensor_info.nMaxHeight.value,
'color_mode': self.sensor_info.nColorMode.value,
'pixel_size': self.sensor_info.wPixelSize.value / 100.0, # in µm
}
def __del__(self):
"""Destructor - ensure cleanup."""
self.cleanup()
class CameraStreamThread(QThread):
"""
Thread for continuous camera frame acquisition and streaming.
"""
frame_ready = pyqtSignal(QImage)
error_occurred = pyqtSignal(str)
def __init__(self, camera: UC480Camera):
"""
Initialize the camera stream thread.
Args:
camera: UC480Camera instance
"""
super().__init__()
self.camera = camera
self.running = False
def run(self):
"""Main thread loop for frame acquisition."""
self.running = True
if not self.camera.start_capture():
self.error_occurred.emit("Failed to start camera capture")
return
while self.running:
frame = self.camera.get_frame()
if frame is not None:
self.frame_ready.emit(frame)
else:
# Small delay on error to prevent CPU spinning
self.msleep(10)
self.camera.stop_capture()
def stop(self):
"""Stop the streaming thread."""
self.running = False
self.wait()
-207
View File
@@ -1,207 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
-2
View File
@@ -1,2 +0,0 @@
# nuescan
SRAS Scan Planning and Control Software
-161
View File
@@ -1,161 +0,0 @@
# nueScan Setup Guide
## Installation
### Prerequisites
- Python 3.8 or higher
- pip package manager
### Install Dependencies
```bash
pip install -r requirements.txt
```
## Running the Application
### Method 1: Run as module
```bash
python -m nuescan
```
### Method 2: Run __main__.py directly
```bash
python __main__.py
```
## Project Structure
```
nuescan/
├── __main__.py # Application entry point
├── main_window.py # Main window controller
├── requirements.txt # Python dependencies
├── SETUP.md # This file
│
├── dialogs/ # Dialog controllers
│ ├── __init__.py
│ ├── genesis_dialog.py # Genesis settings dialog
│ ├── helios_dialog.py # Helios settings dialog
│ └── scan_active_dialog.py # Scan progress dialog
│
├── hardware/ # Hardware interface stubs
│ ├── __init__.py
│ ├── thorlabs_stage.py # ThorLabs MLS stage controller
│ ├── t3r_device.py # T3R-SL device controller
│ └── microscope.py # Genesis/Helios controller
│
└── *.ui # Qt Designer UI files
```
## Hardware Interfaces
### Current Implementation Status
Hardware module implementation status:
- **ThorLabs BBD203 Stage**: ✅ **FULLY IMPLEMENTED** - Production ready
- **Helios Laser System**: ✅ **FULLY IMPLEMENTED** - Production ready
- **Genesis Microscope**: Stub implementation for development
- **T3R-SL Device**: Stub implementation for development
#### ThorLabs MLS Stage (BBD203 Motor Controller)
- **File**: `hardware/thorlabs_stage.py`
- **Purpose**: 3-axis positioning control via BBD203 controller
- **Connection**: USB with serial number auto-detection
- **Status**: Full implementation - ready for real hardware
- **Protocol**: APT binary protocol v42.1
- **Usage**: Enter BBD203 serial number in UI, driver auto-finds USB port
#### Helios Laser System
- **Files**: `hardware/helios_driver.py`, `hardware/helios_protocol.py`
- **Purpose**: Laser control with frequency, current, and pulse mode settings
- **Connection**: RS-232 serial (9600 baud, 8N1)
- **Status**: Full implementation - ready for real hardware
- **Protocol**: ASCII-based RS-232 protocol
- **Features**:
- Frequency control (16.7-125 kHz)
- Current control (0-7000 mA)
- Pulse mode control (single, gating, continuous)
- Temperature monitoring (4 sensors)
- Power monitoring
- Status register with error detection
- **Usage**: Configure via Helios Settings dialog, COM port selected from dropdown
#### Genesis Microscope
- **File**: `hardware/microscope.py` (Genesis methods)
- **Purpose**: Laser scanning microscope system
- **Connection**: USB/Serial (not implemented)
- **Status**: Stub - simulates microscope connection and status
#### T3R-SL Device
- **File**: `hardware/t3r_device.py`
- **Purpose**: Timing and trigger control
- **Connection**: USB/Serial (COM port)
- **Status**: Stub - simulates device connection and status
### Implementing Real Hardware Support
To add actual hardware support, modify the stub methods in the respective hardware files:
1. Add real serial communication using `pyserial`
2. Implement manufacturer-specific protocols
3. Add error handling and timeout logic
4. Implement actual status polling from devices
## UI Event Connections
All UI elements are connected to handler methods:
### Buttons
- ThorLabs stage connect/disconnect
- COM port refresh and connect
- Genesis/Helios settings dialogs
- Begin scanning
- Advanced oscilloscope settings
### ComboBoxes
- COM port selection
- Number of scans
- Row spacing
- Oscilloscope channel selections
### Text Inputs
- Stage serial number
- Scan coordinates (X/Y start, delta)
- Trigger voltage
- VISA address
## Development Notes
### Adding New Hardware
1. Create new controller class in `hardware/` directory
2. Import and instantiate in `main_window.py`
3. Add status update methods
4. Connect to UI elements as needed
### Modifying UI
1. Edit `.ui` files with Qt Designer
2. UI elements are accessed by their object names
3. Connections are made in `_connect_signals()` method
### Debug Output
All stub methods print debug information to console. Look for:
- `DEBUG:` - Function calls and state changes
- `INFO:` - Successful operations
- `WARNING:` - Potential issues
- `ERROR:` - Operation failures
## Testing
The application can be run without any hardware connected. All hardware interfaces will simulate proper responses.
### Test Progress Dialog
To test the scan progress dialog with simulated progress:
1. Configure scan parameters
2. Click "Begin Scan"
3. The dialog will show with demo progress animation
## License
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
-30
View File
@@ -1,30 +0,0 @@
#!/usr/bin/env python3
"""
nueScan - SRAS Scan Planning and Control Software
Entry point for the application
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import sys
from PyQt6.QtWidgets import QApplication
from main_window import NueScanMainWindow
def main():
"""Main entry point for nueScan application"""
app = QApplication(sys.argv)
app.setApplicationName("nueScan")
app.setOrganizationName("SRAS")
app.setApplicationVersion("0.1.0")
# Create and show main window
main_window = NueScanMainWindow()
main_window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
-3
View File
@@ -1,3 +0,0 @@
"""
Dialog controllers for nueScan application
"""
-71
View File
@@ -1,71 +0,0 @@
"""
Genesis Laser Settings Dialog
Configures Genesis scanning laser parameters
"""
import os
from PyQt6 import uic
from PyQt6.QtWidgets import QDialog
class GenesisDialog(QDialog):
"""Dialog for configuring Genesis laser settings"""
def __init__(self, parent=None):
super().__init__(parent)
# Load UI file
ui_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
'nuescan_genesis_dialog.ui'
)
uic.loadUi(ui_path, self)
self.setWindowTitle("Genesis Laser Settings")
# Initialize with default values
self._load_default_settings()
# Connect signals
self._connect_signals()
def _connect_signals(self):
"""Connect dialog signals"""
# LineEdit text changed
self.le_genesis_power_mw.textChanged.connect(self.on_power_changed)
# Dialog buttons are auto-connected by Qt Designer
def _load_default_settings(self):
"""Load default Genesis settings"""
self.le_genesis_power_mw.setText("100.0") # Default 100mW
def on_power_changed(self, text):
"""Handle scanning power change"""
print(f"DEBUG: Genesis power changed to: {text}")
def get_settings(self):
"""
Get current Genesis settings as a dictionary
Returns:
dict: Genesis laser settings
"""
try:
power_mw = float(self.le_genesis_power_mw.text())
except ValueError:
power_mw = 0.0
return {
'power_mw': power_mw
}
def set_settings(self, settings):
"""
Set Genesis settings from a dictionary
Args:
settings (dict): Genesis laser settings
"""
if 'power_mw' in settings:
self.le_genesis_power_mw.setText(str(settings['power_mw']))
-169
View File
@@ -1,169 +0,0 @@
"""
Helios Device Settings Dialog
Configures Helios laser parameters and COM port
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import os
from PyQt6 import uic
from PyQt6.QtWidgets import QDialog, QMessageBox
from hardware.helios_driver import HeliosDriver
class HeliosDialog(QDialog):
"""Dialog for configuring Helios device settings"""
def __init__(self, parent=None):
super().__init__(parent)
# Load UI file
ui_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
'nuescan_helios_dialog.ui'
)
uic.loadUi(ui_path, self)
self.setWindowTitle("Helios Device Settings")
# Initialize with default values
self._load_default_settings()
# Connect signals
self._connect_signals()
# Populate COM ports
self._populate_com_ports()
def _connect_signals(self):
"""Connect dialog signals"""
# ComboBox value changed
self.cb_helios_port.currentIndexChanged.connect(self.on_port_changed)
# LineEdit text changed
self.le_helios_frequency.textChanged.connect(self.on_frequency_changed)
self.le_helios_current.textChanged.connect(self.on_current_changed)
# Dialog buttons are auto-connected by Qt Designer
def _load_default_settings(self):
"""Load default Helios settings"""
self.le_helios_frequency.setText("10000") # Default 10kHz
self.le_helios_current.setText("500") # Default 500mA
def _populate_com_ports(self):
"""Populate available COM ports"""
# Get available ports from system
ports = HeliosDriver.list_available_ports()
if ports:
self.cb_helios_port.addItems(ports)
print(f"DEBUG: Found {len(ports)} available COM ports")
else:
# No ports found
self.cb_helios_port.addItem("No ports found")
print("WARNING: No COM ports found")
def refresh_com_ports(self):
"""Refresh the COM port list"""
current_port = self.cb_helios_port.currentText()
self.cb_helios_port.clear()
self._populate_com_ports()
# Try to restore previous selection
index = self.cb_helios_port.findText(current_port)
if index >= 0:
self.cb_helios_port.setCurrentIndex(index)
def on_port_changed(self, index):
"""Handle COM port selection change"""
port = self.cb_helios_port.currentText()
print(f"DEBUG: Helios port changed to: {port}")
def on_frequency_changed(self, text):
"""Handle frequency change"""
print(f"DEBUG: Helios frequency changed to: {text}")
def on_current_changed(self, text):
"""Handle current change"""
print(f"DEBUG: Helios current changed to: {text}")
def get_settings(self):
"""
Get current Helios settings as a dictionary
Validates input ranges before returning.
Returns:
dict: Helios device settings, or None if validation fails
"""
# Validate frequency
try:
frequency_hz = float(self.le_helios_frequency.text())
# Convert to period to check valid range (8000-60000 ns)
# Valid frequencies: ~16.7 kHz to 125 kHz
if frequency_hz < 16666 or frequency_hz > 125000:
QMessageBox.warning(
self, "Invalid Frequency",
f"Frequency must be between 16.7 kHz and 125 kHz\n"
f"(Period: 8000-60000 ns)\n\n"
f"Entered: {frequency_hz/1000:.1f} kHz"
)
return None
except ValueError:
QMessageBox.warning(
self, "Invalid Frequency",
"Please enter a valid frequency value in Hz"
)
return None
# Validate current
try:
current_ma = float(self.le_helios_current.text())
if current_ma < 0 or current_ma > 7000:
QMessageBox.warning(
self, "Invalid Current",
f"Current must be between 0 and 7000 mA\n\n"
f"Entered: {current_ma} mA"
)
return None
except ValueError:
QMessageBox.warning(
self, "Invalid Current",
"Please enter a valid current value in mA"
)
return None
# Validate COM port selection
com_port = self.cb_helios_port.currentText()
if not com_port or com_port == "No ports found":
QMessageBox.warning(
self, "No Port Selected",
"Please select a valid COM port"
)
return None
return {
'com_port': com_port,
'frequency_hz': frequency_hz,
'current_ma': current_ma
}
def set_settings(self, settings):
"""
Set Helios settings from a dictionary
Args:
settings (dict): Helios device settings
"""
if 'com_port' in settings:
index = self.cb_helios_port.findText(settings['com_port'])
if index >= 0:
self.cb_helios_port.setCurrentIndex(index)
if 'frequency_hz' in settings:
self.le_helios_frequency.setText(str(settings['frequency_hz']))
if 'current_ma' in settings:
self.le_helios_current.setText(str(settings['current_ma']))
-126
View File
@@ -1,126 +0,0 @@
"""
nueScan - Oscilloscope Dialog Controller
Handles all UI interactions for the oscilloscope dialog
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import os
from PyQt6 import uic
from PyQt6.QtWidgets import QDialog, QMessageBox
class OscopeDialog(QDialog):
"""Oscope dialog for nueScan application"""
def __init__(self, parent, microscope):
super().__init__(parent)
# Store hardware controllers
self.microscope = microscope
# Load UI file
ui_path = os.path.join(os.path.dirname(__file__), '..', 'nuescan_oscope_dialog.ui')
uic.loadUi(ui_path, self)
# Set window title
self.setWindowTitle("nueScan - Oscilloscope Settings")
# Connect all UI signals
self._connect_signals()
# Initialize UI state
self._initialize_ui()
def _connect_signals(self):
"""Connect all UI signals to handler methods"""
# ===== ComboBox Value Changed Handlers =====
self.cb_set_trig_channel.currentIndexChanged.connect(self.on_trigger_channel_changed)
self.cb_set_saw_channel.currentIndexChanged.connect(self.on_saw_channel_changed)
self.cb_set_bias_a_ch.currentIndexChanged.connect(self.on_bias_a_channel_changed)
self.cb_set_bias_b_ch.currentIndexChanged.connect(self.on_bias_b_channel_changed)
# ===== LineEdit Text Changed Handlers =====
self.le_set_trigger_voltage.textChanged.connect(self.on_trigger_voltage_changed)
self.le_set_sample_thresh_voltage.textChanged.connect(self.on_sample_thresh_voltage_changed)
self.le_set_pd_trig_voltage.textChanged.connect(self.on_pd_trig_voltage_changed)
self.le_oscope_visa_address.textChanged.connect(self.on_oscope_visa_address_changed)
# ===== Button Click Handlers =====
self.btn_test_scope_connection.clicked.connect(self.on_test_scope_connection_clicked)
self.btn_save_scope_settings.clicked.connect(self.on_save_scope_settings_clicked)
self.btn_cancel_scope_settings.clicked.connect(self.on_cancel_scope_settings_clicked)
def _initialize_ui(self):
"""Initialize UI with default values"""
# Populate combo boxes with dummy data
self._populate_combo_boxes()
def _populate_combo_boxes(self):
"""Populate all combo boxes with initial values"""
# Oscilloscope channels
channels = ["CH1", "CH2", "CH3", "CH4"]
self.cb_set_trig_channel.addItems(channels)
self.cb_set_saw_channel.addItems(channels)
self.cb_set_bias_a_ch.addItems(channels)
self.cb_set_bias_b_ch.addItems(channels)
# ==================== ComboBox Change Handlers ====================
def on_trigger_channel_changed(self, index):
"""Handle Phototrigger channel change"""
channel = self.cb_set_trig_channel.currentText()
print(f"DEBUG: Phototrigger channel changed to: {channel}")
def on_bias_a_channel_changed(self, index):
"""Handle Bias A channel change"""
channel = self.cb_set_bias_a_ch.currentText()
print(f"DEBUG: Bias A channel changed to: {channel}")
def on_bias_b_channel_changed(self, index):
"""Handle Bias B channel change"""
channel = self.cb_set_bias_b_ch.currentText()
print(f"DEBUG: Bias B channel changed to: {channel}")
def on_saw_channel_changed(self, index):
"""Handle RF/SAW channel change"""
channel = self.cb_set_saw_channel.currentText()
print(f"DEBUG: RF/SAW channel changed to: {channel}")
# ==================== LineEdit Text Change Handlers ====================
def on_pd_trig_voltage_changed(self, text):
"""Handle PD Trigger voltage change"""
print(f"DEBUG: PD Trigger voltage changed to: {text}")
def on_trigger_voltage_changed(self, text):
"""Handle Sample Min Bias voltage change"""
print(f"DEBUG: Sample Min Bias voltage changed to: {text}")
def on_sample_thresh_voltage_changed(self, text):
"""Handle Sample Min Bias voltage change (placeholder)"""
print(f"DEBUG: Sample threshold voltage changed to: {text}")
def on_oscope_visa_address_changed(self, text):
"""Handle oscilloscope VISA address change"""
print(f"DEBUG: Oscilloscope VISA address changed to: {text}")
# ==================== Button Click Handlers ====================
def on_test_scope_connection_clicked(self):
"""Test the oscilloscope connection"""
print("DEBUG: Test oscope connection clicked")
def on_save_scope_settings_clicked(self):
"""Save the oscilloscope settings"""
print("DEBUG: Save oscope settings clicked")
self.accept()
def on_cancel_scope_settings_clicked(self):
"""Cancel the oscilloscope settings changes"""
print("DEBUG: Cancel oscope settings clicked")
self.reject()
-163
View File
@@ -1,163 +0,0 @@
"""
Scan Active Dialog
Displays real-time scanning progress and status
"""
import os
from PyQt6 import uic
from PyQt6.QtWidgets import QDialog
from PyQt6.QtCore import QTimer
class ScanActiveDialog(QDialog):
"""Dialog for displaying active scan progress"""
def __init__(self, parent=None):
super().__init__(parent)
# Load UI file
ui_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
'nuescan_scan_active_dialog.ui'
)
uic.loadUi(ui_path, self)
self.setWindowTitle("Scan in Progress")
# Make dialog modal
self.setModal(True)
# Initialize state
self.scan_cancelled = False
# Connect signals
self._connect_signals()
# Initialize progress
self._initialize_progress()
# Demo timer (for testing progress updates)
self._demo_timer = QTimer()
self._demo_timer.timeout.connect(self._demo_update)
self._demo_progress = 0
def _connect_signals(self):
"""Connect dialog signals"""
self.pb_cancel_scan.clicked.connect(self.on_cancel_clicked)
def _initialize_progress(self):
"""Initialize progress bars and status"""
self.pbar_total_scan.setValue(0)
self.pbar_this_scan.setValue(0)
self.l_status_current_scan.setText("1")
self.l_status_total_scans.setText("1")
self.l_status_current_row.setText("0")
self.l_status_total_rows.setText("0")
self.l_est_time_done.setText("Calculating...")
def on_cancel_clicked(self):
"""Handle cancel button click"""
print("DEBUG: Scan cancelled by user")
self.scan_cancelled = True
self.reject()
# ==================== Progress Update Methods ====================
def update_total_progress(self, current, total):
"""
Update the total scan progress bar
Args:
current (int): Current scan number
total (int): Total number of scans
"""
if total > 0:
percentage = int((current / total) * 100)
self.pbar_total_scan.setValue(percentage)
def update_current_scan_progress(self, current, total):
"""
Update the current scan progress bar
Args:
current (int): Current row number
total (int): Total number of rows
"""
if total > 0:
percentage = int((current / total) * 100)
self.pbar_this_scan.setValue(percentage)
def update_status(self, scan_num, total_scans, row_num, total_rows, time_remaining):
"""
Update scan status information
Args:
scan_num (int): Current scan number
total_scans (int): Total number of scans
row_num (int): Current row number
total_rows (int): Total number of rows
time_remaining (str): Estimated time remaining (formatted string)
"""
self.l_status_current_scan.setText(str(scan_num))
self.l_status_total_scans.setText(str(total_scans))
self.l_status_current_row.setText(str(row_num))
self.l_status_total_rows.setText(str(total_rows))
self.l_est_time_done.setText(f"{time_remaining} remaining...")
def start_demo_progress(self):
"""
Start a demo progress animation (for testing)
Remove this method in production
"""
self._demo_progress = 0
self._demo_timer.start(100) # Update every 100ms
def _demo_update(self):
"""
Demo progress update (for testing)
Remove this method in production
"""
self._demo_progress += 1
# Simulate scan progress
total_scans = 5
rows_per_scan = 100
total_steps = total_scans * rows_per_scan
current_scan = (self._demo_progress // rows_per_scan) + 1
current_row = (self._demo_progress % rows_per_scan)
if current_scan > total_scans:
self._demo_timer.stop()
self.accept()
return
# Update progress
self.update_total_progress(current_scan - 1, total_scans)
self.update_current_scan_progress(current_row, rows_per_scan)
# Calculate time remaining (demo)
remaining_steps = total_steps - self._demo_progress
seconds_remaining = remaining_steps * 0.1 # 0.1s per step
hours = int(seconds_remaining // 3600)
minutes = int((seconds_remaining % 3600) // 60)
seconds = int(seconds_remaining % 60)
time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
self.update_status(
current_scan,
total_scans,
current_row,
rows_per_scan,
time_str
)
def is_cancelled(self):
"""
Check if scan was cancelled
Returns:
bool: True if cancelled, False otherwise
"""
return self.scan_cancelled
-101
View File
@@ -1,101 +0,0 @@
"""
nueScan - Status Dialog Controller
Handles all UI interactions for the status dialog
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import os
from PyQt6 import uic
from PyQt6.QtWidgets import QDialog
class StatusDialog(QDialog):
"""Status dialog for nueScan application"""
def __init__(self, parent, thorlabs_stage, t3r_device, microscope):
super().__init__(parent)
# Store hardware controllers
self.thorlabs_stage = thorlabs_stage
self.t3r_device = t3r_device
self.microscope = microscope
# Load UI file
ui_path = os.path.join(os.path.dirname(__file__), '..', 'nuescan_status_dialog.ui')
uic.loadUi(ui_path, self)
# Set window title
self.setWindowTitle("nueScan - Status Indicators")
def update_all_status(self):
"""Update all status labels with current hardware states"""
self._update_stage_status()
self._update_t3r_status()
self._update_microscope_status()
self._update_transfer_system_status()
def _update_stage_status(self):
"""Update ThorLabs stage status indicators"""
status = self.thorlabs_stage.get_status()
self.l_is_mls_connected.setText("Yes" if status['connected'] else "No")
self.l_is_mls_x_home.setText("Yes" if status['x_homed'] else "No")
self.l_is_mls_y_home.setText("Yes" if status['y_homed'] else "No")
self.l_is_mls_ready.setText("Yes" if status['ready'] else "No")
self.l_is_mls_scanning.setText("Yes" if status['scanning'] else "No")
def _update_t3r_status(self):
"""Update T3R device status indicators"""
status = self.t3r_device.get_status()
self.l_is_t3r_connected.setText("Yes" if status['connected'] else "No")
self.l_is_t3r_homed.setText("Yes" if status['homed'] else "No")
self.l_is_t3r_ready.setText("Yes" if status['ready'] else "No")
def _update_microscope_status(self):
"""Update microscope (Genesis/Helios) status indicators"""
status = self.microscope.get_status()
# Helios status
self.l_is_helios_ready.setText("Yes" if status['helios_ready'] else "No")
self.l_is_helios_interlocked.setText("Yes" if status['helios_interlocked'] else "No")
# Genesis status
self.l_is_genesis_ready.setText("Yes" if status['genesis_ready'] else "No")
self.l_is_genesis_interlocked.setText("Yes" if status['genesis_interlocked'] else "No")
def _update_transfer_system_status(self):
"""Update Robo-met.3D transfer system status indicators"""
# Stub implementation - would read from actual I/O
# These represent digital I/O states
io_states = self._read_transfer_io_states()
# SRAS outputs
self.l_sras_ok.setText("High (1)" if io_states['sras_ok'] else "Low (0)")
self.l_sras_ctl.setText("High (1)" if io_states['sras_ctl'] else "Low (0)")
self.l_sras_done.setText("High (1)" if io_states['sras_done'] else "Low (0)")
self.l_sras_error.setText("High (1)" if io_states['sras_error'] else "Low (0)")
# R3D inputs
self.l_r3d_estop_ok.setText("High (1)" if io_states['r3d_estop'] else "Low (0)")
self.l_r3d_rtl.setText("High (1)" if io_states['r3d_ready_to_load'] else "Low (0)")
self.l_r3d_rts.setText("High (1)" if io_states['r3d_ready_to_start'] else "Low (0)")
self.l_r3d_spare.setText("High (1)" if io_states['r3d_spare'] else "Low (0)")
def _read_transfer_io_states(self):
"""
Stub method to read transfer system I/O states
In production, this would read from actual hardware I/O
"""
return {
'sras_ok': False,
'sras_ctl': False,
'sras_done': False,
'sras_error': False,
'r3d_estop': True, # Active low, so True = OK
'r3d_ready_to_load': False,
'r3d_ready_to_start': False,
'r3d_spare': False
}
-4
View File
@@ -1,4 +0,0 @@
"""
Hardware communication modules for nueScan
Handles communication with ThorLabs stage, T3R device, and microscopes
"""
-928
View File
@@ -1,928 +0,0 @@
"""
ThorLabs BBD203 3-Channel Motor Controller Driver
Complete implementation of the APT protocol for BBD203
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import serial
import serial.tools.list_ports
import time
import threading
from typing import Dict, List, Optional, Callable, Tuple
from queue import Queue, Empty
from hardware.bbd203_protocol import (
APTProtocol, APTMessage, MessageID, StatusBits, TriggerMode, Destination
)
class BBD203Channel:
"""Represents a single channel on the BBD203"""
def __init__(self, channel_num: int):
"""
Initialize channel
Args:
channel_num: Channel number (1, 2, or 3)
"""
self.channel_num = channel_num
self.enabled = False
self.homed = False
self.position_mm = 0.0
self.encoder_count = 0
self.status_bits = 0
self.moving = False
self.homing = False
self.error = False
def update_from_status(self, position: int, encoder: int, status: int,
protocol: APTProtocol):
"""Update channel state from status update"""
self.position_mm = protocol.apt_to_position(position)
self.encoder_count = encoder
self.status_bits = status
# Parse status bits
self.homed = bool(status & StatusBits.HOMED)
self.homing = bool(status & StatusBits.HOMING)
self.enabled = bool(status & StatusBits.MOTOR_ENABLED)
self.error = bool(status & StatusBits.MOTION_ERROR)
# Check if moving
self.moving = bool(status & (
StatusBits.IN_MOTION_FORWARD |
StatusBits.IN_MOTION_REVERSE |
StatusBits.JOGGING_FORWARD |
StatusBits.JOGGING_REVERSE |
StatusBits.HOMING
))
def is_ready(self) -> bool:
"""Check if channel is ready for operation"""
return self.enabled and self.homed and not self.error
class BBD203Driver:
"""
Complete driver for ThorLabs BBD203 3-Channel Motor Controller
Features:
- 3 independent motor channels
- Binary APT protocol communication
- Automatic status updates
- Thread-safe operation
- Position and velocity control
"""
def __init__(self, encoder_counts_per_mm: int = 20000, timeout: float = 1.0):
"""
Initialize BBD203 driver
Args:
encoder_counts_per_mm: Encoder resolution (default: 20000 for MLS203)
timeout: Serial communication timeout in seconds
"""
self.protocol = APTProtocol(encoder_counts_per_mm)
self.timeout = timeout
# Serial connection
self._serial: Optional[serial.Serial] = None
self._port_name = ""
self._connected = False
# Channels
self.channels = {
1: BBD203Channel(1),
2: BBD203Channel(2),
3: BBD203Channel(3)
}
# Communication thread
self._rx_thread: Optional[threading.Thread] = None
self._stop_thread = threading.Event()
self._rx_queue = Queue()
# Callbacks for asynchronous events
self._move_complete_callbacks: Dict[int, List[Callable]] = {1: [], 2: [], 3: []}
self._home_complete_callbacks: Dict[int, List[Callable]] = {1: [], 2: [], 3: []}
# Hardware info
self._hw_info = {}
# ==================== Connection Management ====================
@staticmethod
def list_available_ports() -> List[str]:
"""
List available serial ports
Returns:
list: Available port names
"""
ports = serial.tools.list_ports.comports()
return [port.device for port in ports]
@staticmethod
def list_thorlabs_devices() -> List[Dict[str, str]]:
"""
List all ThorLabs APT devices connected via USB
Returns:
list: List of dictionaries containing device information
Each dict has: 'serial', 'port', 'description', 'vid', 'pid'
"""
thorlabs_devices = []
ports = serial.tools.list_ports.comports()
# ThorLabs devices typically use FTDI chips
# Common VID/PID combinations:
# - FTDI: VID=0x0403, various PIDs
thorlabs_vids = [0x0403] # FTDI vendor ID
for port in ports:
# Check if this is a ThorLabs device by VID
if port.vid in thorlabs_vids:
device_info = {
'serial': port.serial_number or 'Unknown',
'port': port.device,
'description': port.description or 'Unknown',
'manufacturer': port.manufacturer or 'Unknown',
'vid': f"0x{port.vid:04X}" if port.vid else 'Unknown',
'pid': f"0x{port.pid:04X}" if port.pid else 'Unknown'
}
thorlabs_devices.append(device_info)
print(f"DEBUG: Found ThorLabs device - Serial: {device_info['serial']}, "
f"Port: {device_info['port']}")
return thorlabs_devices
@staticmethod
def find_device_by_serial(serial_number: str) -> Optional[str]:
"""
Find ThorLabs device by serial number and return its port
Args:
serial_number: Device serial number (e.g., '83123456')
Returns:
str: COM port name if found, None otherwise
"""
devices = BBD203Driver.list_thorlabs_devices()
for device in devices:
if device['serial'] == serial_number:
print(f"INFO: Found device {serial_number} on port {device['port']}")
return device['port']
print(f"WARNING: Device with serial number {serial_number} not found")
print(f"Available devices: {[d['serial'] for d in devices]}")
return None
def connect_by_serial(self, serial_number: str, baudrate: int = 115200) -> bool:
"""
Connect to BBD203 controller by serial number (auto-find port)
This is the preferred connection method - automatically finds the
device by serial number over USB, similar to Kinesis library.
Args:
serial_number: Device serial number (e.g., '83123456')
baudrate: Baud rate (default: 115200)
Returns:
bool: True if connection successful
Example:
driver.connect_by_serial('83123456')
"""
# Find device port by serial number
port = self.find_device_by_serial(serial_number)
if port is None:
print(f"ERROR: Could not find BBD203 with serial number {serial_number}")
print("Available ThorLabs devices:")
for device in self.list_thorlabs_devices():
print(f" Serial: {device['serial']}, Port: {device['port']}, "
f"Description: {device['description']}")
return False
# Connect using the found port
return self.connect(port, baudrate)
def connect(self, port: str, baudrate: int = 115200) -> bool:
"""
Connect to BBD203 controller by port name
Note: It's recommended to use connect_by_serial() instead, which
automatically finds the device by serial number.
Args:
port: Serial port name (e.g., 'COM3' or '/dev/ttyUSB0')
baudrate: Baud rate (default: 115200)
Returns:
bool: True if connection successful
"""
try:
print(f"INFO: Connecting to BBD203 on {port}")
self._serial = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout,
rtscts=False, # Disable hardware flow control
xonxoff=False # Disable software flow control
)
# Set DTR and RTS for ThorLabs FTDI devices
# For BBD203, RTS should be LOW to enable communication
self._serial.dtr = False
self._serial.rts = False
self._port_name = port
self._connected = True
# Give controller time to initialize after connection
time.sleep(0.5)
# Start receive thread
self._stop_thread.clear()
self._rx_thread = threading.Thread(target=self._receive_loop, daemon=True)
self._rx_thread.start()
# Initialize controller
time.sleep(0.5) # Allow thread to start and controller to be ready
# Request hardware info
self._send_command(self.protocol.cmd_req_hw_info())
time.sleep(0.5)
# Start automatic status updates
self._send_command(self.protocol.cmd_start_update_msgs())
time.sleep(0.5)
print(f"INFO: Successfully connected to BBD203 on {port}")
return True
except serial.SerialException as e:
print(f"ERROR: Failed to connect to {port}: {e}")
self._connected = False
return False
def disconnect(self) -> bool:
"""
Disconnect from BBD203 controller
Returns:
bool: True if disconnection successful
"""
if not self._connected:
return True
try:
print("INFO: Disconnecting from BBD203")
# Stop status updates
self._send_command(self.protocol.cmd_stop_update_msgs())
time.sleep(0.1)
# Stop receive thread
self._stop_thread.set()
if self._rx_thread:
self._rx_thread.join(timeout=2.0)
# Close serial port
if self._serial and self._serial.is_open:
self._serial.close()
self._connected = False
print("INFO: Disconnected from BBD203")
return True
except Exception as e:
print(f"ERROR: Error during disconnect: {e}")
return False
def is_connected(self) -> bool:
"""Check if controller is connected"""
return self._connected and self._serial and self._serial.is_open
# ==================== Communication Methods ====================
def _send_command(self, cmd: bytes) -> bool:
"""
Send command to controller
Args:
cmd: Command bytes to send
Returns:
bool: True if send successful
"""
if not self.is_connected():
print("ERROR: Cannot send command - not connected")
return False
try:
print(f"DEBUG: Sending {len(cmd)} bytes: {cmd.hex()}")
self._serial.write(cmd)
self._serial.flush() # Ensure data is sent
return True
except serial.SerialException as e:
print(f"ERROR: Failed to send command: {e}")
return False
def _receive_loop(self):
"""Background thread to receive messages from controller"""
buffer = bytearray()
print("DEBUG: Receive thread started")
while not self._stop_thread.is_set():
try:
if self._serial.in_waiting > 0:
data = self._serial.read(self._serial.in_waiting)
print(f"DEBUG: Received {len(data)} bytes: {data.hex()}")
buffer.extend(data)
# Process complete messages
while len(buffer) >= 6:
# Parse header
msg_id, data_len, dest, source = APTMessage.parse_header(buffer)
# Determine total message length
if data_len == 0 or data_len > 255:
# Header-only message
msg_len = 6
else:
# Message with data
msg_len = 6 + data_len
# Wait for complete message
if len(buffer) < msg_len:
break
# Extract message
msg = bytes(buffer[:msg_len])
buffer = buffer[msg_len:]
# Process message
self._process_message(msg_id, msg)
else:
time.sleep(0.001) # Small delay to prevent busy waiting
except Exception as e:
if not self._stop_thread.is_set():
print(f"ERROR: Exception in receive loop: {e}")
time.sleep(0.1)
def _process_message(self, msg_id: int, msg: bytes):
"""
Process received message
Args:
msg_id: Message ID
msg: Complete message bytes
"""
try:
print(f"DEBUG: Processing message ID 0x{msg_id:04X}, len={len(msg)}, data={msg.hex()}")
if msg_id == MessageID.MGMSG_MOT_GET_STATUSUPDATE:
# Status update
channel, position, encoder, status = APTMessage.parse_status_update(msg)
# Determine which channel this is for (from destination byte)
dest = msg[4]
channel_num = dest - 0x20 # 0x21->1, 0x22->2, 0x23->3
if channel_num in self.channels:
self.channels[channel_num].update_from_status(
position, encoder, status, self.protocol
)
elif msg_id == MessageID.MGMSG_MOT_MOVE_COMPLETED:
# Move completed
dest = msg[4]
channel_num = dest - 0x20
if channel_num in self.channels:
self.channels[channel_num].moving = False
# Call callbacks
for callback in self._move_complete_callbacks.get(channel_num, []):
callback(channel_num)
elif msg_id == MessageID.MGMSG_MOT_MOVE_HOMED:
# Homing completed
dest = msg[4]
channel_num = dest - 0x20
if channel_num in self.channels:
self.channels[channel_num].homed = True
self.channels[channel_num].homing = False
# Call callbacks
for callback in self._home_complete_callbacks.get(channel_num, []):
callback(channel_num)
elif msg_id == MessageID.MGMSG_MOT_MOVE_STOPPED:
# Motion stopped
dest = msg[4]
channel_num = dest - 0x20
if channel_num in self.channels:
self.channels[channel_num].moving = False
elif msg_id == MessageID.MGMSG_MOD_GET_CHANENABLESTATE:
# Channel enable state
channel, enabled = APTMessage.parse_channel_enable_state(msg)
print(f"DEBUG: Received CHANENABLESTATE - parsed channel={channel}, enabled={enabled}, msg={msg.hex()}")
# Use the channel number from the parsed message
if channel in self.channels:
self.channels[channel].enabled = enabled
print(f"DEBUG: Set channel {channel} enabled={enabled}")
elif msg_id == MessageID.MGMSG_HW_RESPONSE:
# Hardware response (error or acknowledgement)
print(f"DEBUG: Received HW_RESPONSE: {msg.hex()}")
elif msg_id == MessageID.MGMSG_HW_GET_INFO:
# Hardware info
print(f"DEBUG: Received hardware info")
except Exception as e:
print(f"ERROR: Failed to process message {msg_id:04X}: {e}")
def _set_and_verify_enable(self, channel: int, enable: bool, retries: int = 3) -> bool:
"""
Set channel enable state and verify it was set correctly
Args:
channel: Channel number (1, 2, or 3)
enable: True to enable, False to disable
retries: Number of retry attempts
Returns:
bool: True if value was set and verified
"""
for attempt in range(retries):
# Send enable command
cmd = self.protocol.cmd_enable_channel(channel, enable)
if not self._send_command(cmd):
continue
time.sleep(0.5) # Wait for controller to process
# Request channel enable state to verify
req_cmd = self.protocol.cmd_req_channel_enable_state(channel)
self._send_command(req_cmd)
time.sleep(0.5) # Wait for response
# Check if state matches expected
if self.channels[channel].enabled == enable:
return True
if attempt < retries - 1:
print(f"DEBUG: Enable verification failed for channel {channel}, "
f"retrying ({attempt + 1}/{retries})")
time.sleep(0.2)
print(f"ERROR: Failed to set and verify enable state for channel {channel} "
f"after {retries} attempts")
return False
# ==================== Channel Control ====================
def enable_channel(self, channel: int, enable: bool = True) -> bool:
"""
Enable or disable a motor channel
Args:
channel: Channel number (1, 2, or 3)
enable: True to enable, False to disable
Returns:
bool: True if command sent successfully
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
action = "Enabling" if enable else "Disabling"
print(f"DEBUG: {action} channel {channel}")
# Use set and verify to ensure command was processed
return self._set_and_verify_enable(channel, enable)
def identify(self, channel: int) -> bool:
"""
Flash front panel LEDs to identify controller
Args:
channel: Channel number (1, 2, or 3)
Returns:
bool: True if command sent successfully
"""
print(f"DEBUG: Identifying channel {channel}")
cmd = self.protocol.cmd_identify(channel)
return self._send_command(cmd)
# ==================== Homing ====================
def home_channel(self, channel: int, wait: bool = False, timeout: float = 30.0) -> bool:
"""
Home a motor channel
Args:
channel: Channel number (1, 2, or 3)
wait: If True, block until homing complete
timeout: Timeout in seconds if waiting
Returns:
bool: True if homing initiated (or completed if wait=True)
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
print(f"DEBUG: Homing channel {channel}")
self.channels[channel].homing = True
self.channels[channel].homed = False
cmd = self.protocol.cmd_move_home(channel)
if not self._send_command(cmd):
return False
if wait:
# Wait for homing to complete
start_time = time.time()
while time.time() - start_time < timeout:
if self.channels[channel].homed and not self.channels[channel].homing:
print(f"INFO: Channel {channel} homing completed")
return True
time.sleep(0.1)
print(f"ERROR: Homing timeout for channel {channel}")
return False
return True
def home_all_channels(self, wait: bool = False, timeout: float = 30.0) -> bool:
"""
Home all enabled channels
Args:
wait: If True, block until all homing complete
timeout: Timeout in seconds if waiting
Returns:
bool: True if all homing operations successful
"""
success = True
for channel in [1, 2, 3]:
if self.channels[channel].enabled:
if not self.home_channel(channel, wait=False):
success = False
if wait:
start_time = time.time()
while time.time() - start_time < timeout:
all_homed = all(
ch.homed for ch in self.channels.values() if ch.enabled
)
if all_homed:
print("INFO: All channels homed successfully")
return True
time.sleep(0.1)
print("ERROR: Timeout waiting for all channels to home")
return False
return success
# ==================== Motion Control ====================
def move_absolute(self, channel: int, position_mm: float,
wait: bool = False, timeout: float = 30.0) -> bool:
"""
Move to absolute position
Args:
channel: Channel number (1, 2, or 3)
position_mm: Target position in mm
wait: If True, block until move complete
timeout: Timeout in seconds if waiting
Returns:
bool: True if move initiated (or completed if wait=True)
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
if not self.channels[channel].is_ready():
print(f"ERROR: Channel {channel} not ready for movement")
return False
print(f"DEBUG: Moving channel {channel} to {position_mm} mm")
self.channels[channel].moving = True
cmd = self.protocol.cmd_move_absolute(channel, position_mm)
if not self._send_command(cmd):
return False
if wait:
# Wait for move to complete
start_time = time.time()
while time.time() - start_time < timeout:
if not self.channels[channel].moving:
print(f"INFO: Channel {channel} move completed")
return True
time.sleep(0.01)
print(f"ERROR: Move timeout for channel {channel}")
return False
return True
def move_relative(self, channel: int, distance_mm: float,
wait: bool = False, timeout: float = 30.0) -> bool:
"""
Move relative distance
Args:
channel: Channel number (1, 2, or 3)
distance_mm: Distance to move in mm (positive or negative)
wait: If True, block until move complete
timeout: Timeout in seconds if waiting
Returns:
bool: True if move initiated (or completed if wait=True)
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
if not self.channels[channel].is_ready():
print(f"ERROR: Channel {channel} not ready for movement")
return False
print(f"DEBUG: Moving channel {channel} by {distance_mm} mm")
self.channels[channel].moving = True
cmd = self.protocol.cmd_move_relative(channel, distance_mm)
if not self._send_command(cmd):
return False
if wait:
# Wait for move to complete
start_time = time.time()
while time.time() - start_time < timeout:
if not self.channels[channel].moving:
print(f"INFO: Channel {channel} move completed")
return True
time.sleep(0.01)
print(f"ERROR: Move timeout for channel {channel}")
return False
return True
def stop(self, channel: int, immediate: bool = True) -> bool:
"""
Stop motion
Args:
channel: Channel number (1, 2, or 3), or 0 for all channels
immediate: If True, stop immediately; if False, decelerate
Returns:
bool: True if stop command sent successfully
"""
if channel == 0:
# Stop all channels
success = True
for ch in [1, 2, 3]:
if not self.stop(ch, immediate):
success = False
return success
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
print(f"DEBUG: Stopping channel {channel}")
cmd = self.protocol.cmd_move_stop(channel, immediate)
return self._send_command(cmd)
# ==================== Parameter Setting ====================
def set_velocity_params(self, channel: int, max_vel_mm_s: float,
accel_mm_s2: float) -> bool:
"""
Set velocity and acceleration parameters
Args:
channel: Channel number (1, 2, or 3)
max_vel_mm_s: Maximum velocity in mm/s
accel_mm_s2: Acceleration in mm/s²
Returns:
bool: True if parameters set successfully
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
print(f"DEBUG: Setting velocity params for channel {channel}: "
f"vel={max_vel_mm_s} mm/s, accel={accel_mm_s2} mm/s²")
cmd = self.protocol.cmd_set_velocity_params(channel, max_vel_mm_s, accel_mm_s2)
if self._send_command(cmd):
time.sleep(0.1) # Wait for controller to process
# Request status update to confirm parameters were accepted
self.request_status_update(channel)
time.sleep(0.1) # Wait for status response
return True
return False
# ==================== Status and Position ====================
def get_position(self, channel: int) -> Optional[float]:
"""
Get current position of channel
Args:
channel: Channel number (1, 2, or 3)
Returns:
float: Current position in mm, or None if unavailable
"""
if channel not in [1, 2, 3]:
return None
return self.channels[channel].position_mm
def get_channel_status(self, channel: int) -> Optional[Dict]:
"""
Get detailed status of channel
Args:
channel: Channel number (1, 2, or 3)
Returns:
dict: Channel status dictionary
"""
if channel not in [1, 2, 3]:
return None
ch = self.channels[channel]
return {
'channel': channel,
'enabled': ch.enabled,
'homed': ch.homed,
'homing': ch.homing,
'moving': ch.moving,
'error': ch.error,
'ready': ch.is_ready(),
'position_mm': ch.position_mm,
'encoder_count': ch.encoder_count,
'status_bits': ch.status_bits
}
def request_status_update(self, channel: int) -> bool:
"""
Request immediate status update for channel
Args:
channel: Channel number (1, 2, or 3)
Returns:
bool: True if request sent successfully
"""
if channel not in [1, 2, 3]:
return False
cmd = self.protocol.cmd_req_status_update(channel)
return self._send_command(cmd)
# ==================== Callbacks ====================
def register_move_complete_callback(self, channel: int, callback: Callable):
"""Register callback for move complete event"""
if channel in [1, 2, 3]:
self._move_complete_callbacks[channel].append(callback)
def register_home_complete_callback(self, channel: int, callback: Callable):
"""Register callback for home complete event"""
if channel in [1, 2, 3]:
self._home_complete_callbacks[channel].append(callback)
# ==================== Trigger Configuration ====================
def set_trigger_mode(self, channel: int, mode: int, polarity: int = 0x01,
start_pos_fwd: float = 0.0, start_pos_rev: float = 0.0,
interval_fwd: float = 0.0, interval_rev: float = 0.0) -> bool:
"""
Set trigger configuration for a channel
Args:
channel: Channel number (1, 2, or 3)
mode: Trigger mode (TriggerMode enum value)
polarity: Trigger polarity (0x01 = active high, 0x02 = active low)
start_pos_fwd: Start position for forward trigger (mm)
start_pos_rev: Start position for reverse trigger (mm)
interval_fwd: Interval for forward trigger (mm)
interval_rev: Interval for reverse trigger (mm)
Returns:
bool: True if trigger configuration set successfully
Example:
# Disable trigger
driver.set_trigger_mode(1, TriggerMode.DISABLED)
# Enable trigger output on motion
driver.set_trigger_mode(1, TriggerMode.OUT_ONLY)
# Trigger at specific positions
driver.set_trigger_mode(1, TriggerMode.OUT_POSITION,
start_pos_fwd=10.0, interval_fwd=1.0)
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
print(f"DEBUG: Setting trigger mode for channel {channel}: mode={mode}")
cmd = self.protocol.cmd_set_trigger(
channel, mode, polarity, start_pos_fwd, start_pos_rev,
interval_fwd, interval_rev
)
if self._send_command(cmd):
time.sleep(0.1) # Wait for controller to process
return True
return False
def get_trigger_config(self, channel: int) -> Optional[Dict]:
"""
Get current trigger configuration for a channel
Args:
channel: Channel number (1, 2, or 3)
Returns:
dict: Trigger configuration or None if unavailable
"""
if channel not in [1, 2, 3]:
return None
cmd = self.protocol.cmd_req_trigger(channel)
if not self._send_command(cmd):
return None
# Note: In a complete implementation, would wait for response
# For now, returning None as response handling would need queue
print("WARNING: get_trigger_config not fully implemented (requires response queue)")
return None
# ==================== Digital I/O ====================
def set_digital_outputs(self, output_bits: int) -> bool:
"""
Set digital output states
Args:
output_bits: Bit pattern for outputs (0x00 to 0xFF)
Returns:
bool: True if digital outputs set successfully
Note:
Digital outputs share pins with trigger outputs. Ensure
trigger mode is disabled before using digital outputs.
"""
if not (0 <= output_bits <= 0xFF):
print(f"ERROR: Invalid output bits: {output_bits}")
return False
print(f"DEBUG: Setting digital outputs: 0x{output_bits:02X}")
cmd = self.protocol.cmd_set_digital_outputs(output_bits)
if self._send_command(cmd):
time.sleep(0.05)
return True
return False
-532
View File
@@ -1,532 +0,0 @@
"""
ThorLabs BBD203 APT Protocol Handler
Binary message protocol for BBD203 motor controller
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import struct
from enum import IntEnum
from typing import Tuple, Optional, List
# Message IDs
class MessageID(IntEnum):
"""APT Protocol Message IDs for BBD203"""
# Module Control
MGMSG_MOD_IDENTIFY = 0x0223
MGMSG_MOD_SET_CHANENABLESTATE = 0x0210
MGMSG_MOD_REQ_CHANENABLESTATE = 0x0211
MGMSG_MOD_GET_CHANENABLESTATE = 0x0212
# Hardware Control
MGMSG_HW_DISCONNECT = 0x0002
MGMSG_HW_RESPONSE = 0x0080
MGMSG_HW_RICHRESPONSE = 0x0081
MGMSG_HW_START_UPDATEMSGS = 0x0011
MGMSG_HW_STOP_UPDATEMSGS = 0x0012
MGMSG_HW_REQ_INFO = 0x0005
MGMSG_HW_GET_INFO = 0x0006
# Motor Control - Basic
MGMSG_MOT_SET_POSCOUNTER = 0x0410
MGMSG_MOT_REQ_POSCOUNTER = 0x0411
MGMSG_MOT_GET_POSCOUNTER = 0x0412
MGMSG_MOT_SET_ENCCOUNTER = 0x0409
MGMSG_MOT_REQ_ENCCOUNTER = 0x040A
MGMSG_MOT_GET_ENCCOUNTER = 0x040B
# Motor Control - Homing
MGMSG_MOT_SET_HOMEPARAMS = 0x0440
MGMSG_MOT_REQ_HOMEPARAMS = 0x0441
MGMSG_MOT_GET_HOMEPARAMS = 0x0442
MGMSG_MOT_MOVE_HOME = 0x0443
MGMSG_MOT_MOVE_HOMED = 0x0444
# Motor Control - Movement
MGMSG_MOT_SET_MOVERELPARAMS = 0x0445
MGMSG_MOT_REQ_MOVERELPARAMS = 0x0446
MGMSG_MOT_GET_MOVERELPARAMS = 0x0447
MGMSG_MOT_MOVE_RELATIVE = 0x0448
MGMSG_MOT_SET_MOVEABSPARAMS = 0x0450
MGMSG_MOT_REQ_MOVEABSPARAMS = 0x0451
MGMSG_MOT_GET_MOVEABSPARAMS = 0x0452
MGMSG_MOT_MOVE_ABSOLUTE = 0x0453
MGMSG_MOT_MOVE_COMPLETED = 0x0464
MGMSG_MOT_MOVE_VELOCITY = 0x0457
MGMSG_MOT_MOVE_STOP = 0x0465
MGMSG_MOT_MOVE_STOPPED = 0x0466
# Motor Control - Velocity
MGMSG_MOT_SET_VELPARAMS = 0x0413
MGMSG_MOT_REQ_VELPARAMS = 0x0414
MGMSG_MOT_GET_VELPARAMS = 0x0415
# Motor Control - Status
MGMSG_MOT_REQ_STATUSUPDATE = 0x0480
MGMSG_MOT_GET_STATUSUPDATE = 0x0481
MGMSG_MOT_REQ_STATUSBITS = 0x0429
MGMSG_MOT_GET_STATUSBITS = 0x042A
# Digital I/O and Trigger
MGMSG_RACK_SET_DIGOUTPUTS = 0x0228
MGMSG_RACK_REQ_DIGOUTPUTS = 0x0229
MGMSG_RACK_GET_DIGOUTPUTS = 0x0230
MGMSG_MOT_SET_TRIGGER = 0x0500
MGMSG_MOT_REQ_TRIGGER = 0x0501
MGMSG_MOT_GET_TRIGGER = 0x0502
# Destination addresses
class Destination(IntEnum):
"""BBD203 Destination addresses"""
USB = 0x50
ALL_CHANNELS = 0x11
CHANNEL_1 = 0x21
CHANNEL_2 = 0x22
CHANNEL_3 = 0x23
# Source addresses
class Source(IntEnum):
"""Source addresses"""
HOST = 0x01
# Status bits
class StatusBits(IntEnum):
"""Motor status bit definitions"""
HOMING = 0x00000200
HOMED = 0x00000400
TRACKING = 0x00001000
SETTLED = 0x00002000
MOTION_ERROR = 0x00004000
MOTOR_ENABLED = 0x80000000
FORWARD_LIMIT = 0x00000001
REVERSE_LIMIT = 0x00000002
IN_MOTION_FORWARD = 0x00000010
IN_MOTION_REVERSE = 0x00000020
JOGGING_FORWARD = 0x00000040
JOGGING_REVERSE = 0x00000080
# Trigger modes
class TriggerMode(IntEnum):
"""Trigger mode definitions"""
DISABLED = 0x00
IN_OUT_RELATIVE_MOVE = 0x01
IN_OUT_ABSOLUTE_MOVE = 0x02
IN_OUT_HOME = 0x03
IN_OUT_STOP = 0x04
OUT_ONLY = 0x10
OUT_POSITION = 0x11
class APTMessage:
"""
APT Protocol Message Builder and Parser
Handles construction and parsing of binary APT messages
"""
@staticmethod
def build_header_only(msg_id: int, param1: int, param2: int,
dest: int, source: int = Source.HOST) -> bytes:
"""
Build a 6-byte header-only message
Args:
msg_id: Message ID (16-bit)
param1: Parameter 1 (8-bit)
param2: Parameter 2 (8-bit)
dest: Destination address
source: Source address (default: HOST)
Returns:
bytes: 6-byte message
"""
return struct.pack('<HBBBB', msg_id, param1, param2, dest, source)
@staticmethod
def build_with_data(msg_id: int, dest: int, data: bytes,
source: int = Source.HOST) -> bytes:
"""
Build a message with data packet
Args:
msg_id: Message ID (16-bit)
dest: Destination address
data: Data packet bytes
source: Source address (default: HOST)
Returns:
bytes: Complete message (header + data)
"""
data_len = len(data)
header = struct.pack('<HHBB', msg_id, data_len, dest, source)
return header + data
@staticmethod
def parse_header(data: bytes) -> Tuple[int, int, int, int, int]:
"""
Parse message header
Args:
data: At least 6 bytes of message data
Returns:
tuple: (msg_id, data_len, dest, source, has_data)
"""
if len(data) < 6:
raise ValueError("Insufficient data for header")
msg_id, byte2, byte3, dest, source = struct.unpack('<HBBBB', data[:6])
# Determine if this is header-only or has data
# Header-only messages use bytes 2-3 as parameters
# Messages with data use bytes 2-3 as data length
data_len = (byte3 << 8) | byte2
return msg_id, data_len, dest, source
@staticmethod
def parse_position_counter(data: bytes) -> Tuple[int, int]:
"""Parse MGMSG_MOT_GET_POSCOUNTER response"""
if len(data) < 12:
raise ValueError("Insufficient data for position counter")
_, channel, position = struct.unpack('<HHI', data[6:12])
return channel, position
@staticmethod
def parse_encoder_counter(data: bytes) -> Tuple[int, int]:
"""Parse MGMSG_MOT_GET_ENCCOUNTER response"""
if len(data) < 12:
raise ValueError("Insufficient data for encoder counter")
_, channel, encoder = struct.unpack('<HHI', data[6:12])
return channel, encoder
@staticmethod
def parse_status_update(data: bytes) -> Tuple[int, int, int, int]:
"""
Parse MGMSG_MOT_GET_STATUSUPDATE response
Returns:
tuple: (channel, position, enc_count, status_bits)
"""
if len(data) < 20:
raise ValueError("Insufficient data for status update")
# Skip 6-byte header, parse data packet
channel, position, enc_count, status = struct.unpack('<HIII', data[6:20])
return channel, position, enc_count, status
@staticmethod
def parse_velocity_params(data: bytes) -> Tuple[int, int, int, int]:
"""
Parse MGMSG_MOT_GET_VELPARAMS response
Returns:
tuple: (channel, min_vel, max_vel, accel)
"""
if len(data) < 20:
raise ValueError("Insufficient data for velocity params")
channel, min_vel, max_vel, accel = struct.unpack('<HIII', data[6:20])
return channel, min_vel, max_vel, accel
@staticmethod
def parse_channel_enable_state(data: bytes) -> Tuple[int, bool]:
"""Parse MGMSG_MOD_GET_CHANENABLESTATE response"""
if len(data) < 6:
raise ValueError("Insufficient data for channel enable state")
# Header only message, params in bytes 2-3
_, enable_state, channel, _, _ = struct.unpack('<HBBBB', data[:6])
return channel, (enable_state == 0x01)
@staticmethod
def parse_trigger_config(data: bytes) -> Tuple[int, int, int, int, int, int, int]:
"""
Parse MGMSG_MOT_GET_TRIGGER response
Returns:
tuple: (channel, trigger_mode, polarity, start_pos_fwd, start_pos_rev,
interval_fwd, interval_rev, num_pulses, pulse_width, num_cycles)
"""
if len(data) < 28:
raise ValueError("Insufficient data for trigger config")
# Parse data packet (22 bytes starting at byte 6)
channel, mode, polarity, start_fwd, start_rev, interval_fwd, interval_rev = \
struct.unpack('<HBBIIIi', data[6:28])
# Extended parameters if available
num_pulses = 0
pulse_width = 0
num_cycles = 0
if len(data) >= 40:
num_pulses, pulse_width, num_cycles = struct.unpack('<III', data[28:40])
return (channel, mode, polarity, start_fwd, start_rev,
interval_fwd, interval_rev, num_pulses, pulse_width, num_cycles)
@staticmethod
def parse_digital_outputs(data: bytes) -> Tuple[int, int]:
"""Parse MGMSG_RACK_GET_DIGOUTPUTS response"""
if len(data) < 6:
raise ValueError("Insufficient data for digital outputs")
# Header only message, params in bytes 2-3
_, output_state, _, _, _ = struct.unpack('<HBBBB', data[:6])
return output_state
class APTProtocol:
"""
High-level APT Protocol interface for BBD203
Provides methods to build common command messages
"""
# Scaling constants
T_SAMPLE = 102.4e-6 # Controller sample time
VELOCITY_SCALE = int(T_SAMPLE * 65536)
ACCEL_SCALE = int((T_SAMPLE ** 2) * 65536)
def __init__(self, encoder_counts_per_mm: int = 20000):
"""
Initialize APT Protocol handler
Args:
encoder_counts_per_mm: Encoder resolution (default: 20000 for MLS203)
"""
self.enc_cnt = encoder_counts_per_mm
def position_to_apt(self, pos_mm: float) -> int:
"""Convert position in mm to APT units"""
return int(pos_mm * self.enc_cnt)
def apt_to_position(self, apt_units: int) -> float:
"""Convert APT units to position in mm"""
return apt_units / self.enc_cnt
def velocity_to_apt(self, vel_mm_s: float) -> int:
"""Convert velocity in mm/s to APT units"""
return int(self.enc_cnt * self.T_SAMPLE * 65536 * vel_mm_s)
def apt_to_velocity(self, apt_units: int) -> float:
"""Convert APT units to velocity in mm/s"""
return apt_units / (self.enc_cnt * self.T_SAMPLE * 65536)
def accel_to_apt(self, accel_mm_s2: float) -> int:
"""Convert acceleration in mm/s² to APT units"""
return int(self.enc_cnt * (self.T_SAMPLE ** 2) * 65536 * accel_mm_s2)
def apt_to_accel(self, apt_units: int) -> float:
"""Convert APT units to acceleration in mm/s²"""
return apt_units / (self.enc_cnt * (self.T_SAMPLE ** 2) * 65536)
# Command builders
def cmd_identify(self, channel: int) -> bytes:
"""Build identify command (flash LEDs)"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOD_IDENTIFY, 0x00, 0x00, dest
)
def cmd_enable_channel(self, channel: int, enable: bool = True) -> bytes:
"""Build enable/disable channel command"""
dest = Destination.CHANNEL_1 + (channel - 1)
state = 0x01 if enable else 0x02
return APTMessage.build_header_only(
MessageID.MGMSG_MOD_SET_CHANENABLESTATE, state, channel, dest
)
def cmd_req_channel_enable_state(self, channel: int) -> bytes:
"""Build request channel enable state command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOD_REQ_CHANENABLESTATE, 0x01, 0x00, dest
)
def cmd_start_update_msgs(self) -> bytes:
"""Build start automatic status updates command"""
return APTMessage.build_header_only(
MessageID.MGMSG_HW_START_UPDATEMSGS, 0x00, 0x00, Destination.USB
)
def cmd_stop_update_msgs(self) -> bytes:
"""Build stop automatic status updates command"""
return APTMessage.build_header_only(
MessageID.MGMSG_HW_STOP_UPDATEMSGS, 0x00, 0x00, Destination.USB
)
def cmd_req_hw_info(self) -> bytes:
"""Build request hardware info command"""
return APTMessage.build_header_only(
MessageID.MGMSG_HW_REQ_INFO, 0x00, 0x00, Destination.USB
)
def cmd_move_home(self, channel: int) -> bytes:
"""Build move home command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_MOVE_HOME, 0x01, 0x00, dest
)
def cmd_move_absolute(self, channel: int, position_mm: float) -> bytes:
"""Build move absolute command"""
dest = Destination.CHANNEL_1 + (channel - 1)
pos_apt = self.position_to_apt(position_mm)
data = struct.pack('<HI', channel, pos_apt)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_MOVE_ABSOLUTE, dest, data
)
def cmd_move_relative(self, channel: int, distance_mm: float) -> bytes:
"""Build move relative command"""
dest = Destination.CHANNEL_1 + (channel - 1)
dist_apt = self.position_to_apt(distance_mm)
data = struct.pack('<Hi', channel, dist_apt)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_MOVE_RELATIVE, dest, data
)
def cmd_move_stop(self, channel: int, immediate: bool = True) -> bytes:
"""Build stop motion command"""
dest = Destination.CHANNEL_1 + (channel - 1)
stop_mode = 0x01 if immediate else 0x02
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_MOVE_STOP, 0x01, stop_mode, dest
)
def cmd_set_velocity_params(self, channel: int, max_vel_mm_s: float,
accel_mm_s2: float) -> bytes:
"""Build set velocity parameters command"""
dest = Destination.CHANNEL_1 + (channel - 1)
max_vel_apt = self.velocity_to_apt(max_vel_mm_s)
accel_apt = self.accel_to_apt(accel_mm_s2)
data = struct.pack('<HIII',
channel, # Channel number
0, # Min velocity (0)
max_vel_apt, # Max velocity
accel_apt # Acceleration
)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_SET_VELPARAMS, dest, data
)
def cmd_req_velocity_params(self, channel: int) -> bytes:
"""Build request velocity parameters command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_VELPARAMS, 0x01, 0x00, dest
)
def cmd_req_position(self, channel: int) -> bytes:
"""Build request position counter command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_POSCOUNTER, 0x01, 0x00, dest
)
def cmd_req_encoder(self, channel: int) -> bytes:
"""Build request encoder counter command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_ENCCOUNTER, 0x01, 0x00, dest
)
def cmd_req_status_update(self, channel: int) -> bytes:
"""Build request status update command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_STATUSUPDATE, 0x01, 0x00, dest
)
def cmd_req_status_bits(self, channel: int) -> bytes:
"""Build request status bits command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_STATUSBITS, 0x01, 0x00, dest
)
def cmd_set_position_counter(self, channel: int, position_mm: float) -> bytes:
"""Build set position counter command"""
dest = Destination.CHANNEL_1 + (channel - 1)
pos_apt = self.position_to_apt(position_mm)
data = struct.pack('<HI', channel, pos_apt)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_SET_POSCOUNTER, dest, data
)
def cmd_set_trigger(self, channel: int, mode: int, polarity: int = 0x01,
start_pos_fwd: float = 0.0, start_pos_rev: float = 0.0,
interval_fwd: float = 0.0, interval_rev: float = 0.0) -> bytes:
"""
Build set trigger configuration command
Args:
channel: Channel number (1, 2, or 3)
mode: Trigger mode (TriggerMode enum value)
polarity: Trigger polarity (0x01 = active high, 0x02 = active low)
start_pos_fwd: Start position for forward trigger (mm)
start_pos_rev: Start position for reverse trigger (mm)
interval_fwd: Interval for forward trigger (mm)
interval_rev: Interval for reverse trigger (mm)
Returns:
bytes: Complete trigger configuration command
"""
dest = Destination.CHANNEL_1 + (channel - 1)
# Convert positions to APT units
start_fwd_apt = self.position_to_apt(start_pos_fwd)
start_rev_apt = self.position_to_apt(start_pos_rev)
interval_fwd_apt = self.position_to_apt(interval_fwd)
interval_rev_apt = int(self.position_to_apt(interval_rev)) # Signed
data = struct.pack('<HBBIIIi',
channel, # Channel number
mode, # Trigger mode
polarity, # Polarity
start_fwd_apt, # Start position forward
start_rev_apt, # Start position reverse
interval_fwd_apt, # Interval forward
interval_rev_apt # Interval reverse (signed)
)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_SET_TRIGGER, dest, data
)
def cmd_req_trigger(self, channel: int) -> bytes:
"""Build request trigger configuration command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_TRIGGER, 0x01, 0x00, dest
)
def cmd_set_digital_outputs(self, output_bits: int) -> bytes:
"""
Build set digital outputs command
Args:
output_bits: Bit pattern for digital outputs (0x00 to 0xFF)
Returns:
bytes: Digital output command
"""
return APTMessage.build_header_only(
MessageID.MGMSG_RACK_SET_DIGOUTPUTS, output_bits, 0x00, Destination.USB
)
def cmd_req_digital_outputs(self) -> bytes:
"""Build request digital outputs command"""
return APTMessage.build_header_only(
MessageID.MGMSG_RACK_REQ_DIGOUTPUTS, 0x00, 0x00, Destination.USB
)
-627
View File
@@ -1,627 +0,0 @@
"""
Helios Laser Driver
Complete RS-232 driver for Helios laser systems
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import serial
import serial.tools.list_ports
import time
import threading
from typing import Dict, List, Optional, Tuple
from hardware.helios_protocol import (
HeliosProtocol, HeliosCommand, HeliosStatus, PulseMode
)
class HeliosDriver:
"""
Complete driver for Helios laser system
Features:
- RS-232 communication at 9600 baud
- All protocol commands supported
- Thread-safe operation
- Temperature monitoring
- Status monitoring
- Power monitoring
"""
# RS-232 Settings (from protocol document)
BAUDRATE = 9600
DATABITS = 8
PARITY = 'N'
STOPBITS = 1
def __init__(self, timeout: float = 2.0):
"""
Initialize Helios driver
Args:
timeout: Serial communication timeout in seconds
"""
self.protocol = HeliosProtocol()
self.timeout = timeout
# Serial connection
self._serial: Optional[serial.Serial] = None
self._port_name = ""
self._connected = False
# Communication lock for thread safety
self._comm_lock = threading.Lock()
# Cached state
self._laser_enabled = False
self._pulse_mode = PulseMode.CONTINUOUS_PULSING
self._frequency_hz = 10000.0
self._current_ma = 500.0
self._controller_serial = ""
self._head_serial = ""
# Temperature monitoring (in °C)
self._pump_temp_c = 0.0
self._resonator_temp_c = 0.0
self._qswitch_temp_c = 0.0
self._power_stage_temp_c = 0.0
# Status
self._status = HeliosStatus(0)
self._operation_hours = 0.0
self._power_mw = 0.0
print("INFO: Helios Laser driver initialized")
# ==================== Connection Management ====================
@staticmethod
def list_available_ports() -> List[str]:
"""
List available serial ports
Returns:
list: Available port names
"""
ports = serial.tools.list_ports.comports()
return [port.device for port in ports]
def connect(self, port: str) -> bool:
"""
Connect to Helios laser
Args:
port: Serial port name (e.g., 'COM3', '/dev/ttyUSB0')
Returns:
bool: True if connection successful
"""
try:
print(f"INFO: Connecting to Helios laser on {port}")
self._serial = serial.Serial(
port=port,
baudrate=self.BAUDRATE,
bytesize=self.DATABITS,
parity=self.PARITY,
stopbits=self.STOPBITS,
timeout=self.timeout
)
self._port_name = port
self._connected = True
# Read serial numbers
time.sleep(0.5)
self._controller_serial = self.query_controller_serial()
time.sleep(0.5)
self._head_serial = self.query_head_serial()
time.sleep(0.5)
# Read initial state
self._update_cached_state()
print(f"INFO: Connected to Helios laser on {port}")
print(f" Controller S/N: {self._controller_serial}")
print(f" Head S/N: {self._head_serial}")
return True
except serial.SerialException as e:
print(f"ERROR: Failed to connect to {port}: {e}")
self._connected = False
return False
def disconnect(self) -> bool:
"""
Disconnect from Helios laser
Returns:
bool: True if disconnection successful
"""
if not self._connected:
return True
try:
print("INFO: Disconnecting from Helios laser")
# Turn off laser before disconnecting
self.set_laser_enable(False)
time.sleep(0.5)
# Close serial port
if self._serial and self._serial.is_open:
self._serial.close()
self._connected = False
print("INFO: Disconnected from Helios laser")
return True
except Exception as e:
print(f"ERROR: Error during disconnect: {e}")
return False
def is_connected(self) -> bool:
"""Check if laser is connected"""
return self._connected and self._serial and self._serial.is_open
# ==================== Communication Methods ====================
def _send_command(self, command: bytes) -> bool:
"""
Send command to laser (no response expected)
Args:
command: Command bytes to send
Returns:
bool: True if send successful
"""
if not self.is_connected():
print("ERROR: Cannot send command - not connected")
return False
try:
with self._comm_lock:
self._serial.write(command)
self._serial.flush()
return True
except serial.SerialException as e:
print(f"ERROR: Failed to send command: {e}")
return False
def _query(self, command: bytes) -> Optional[str]:
"""
Send query and read response
Args:
command: Query command bytes
Returns:
str: Response string, or None if error
"""
if not self.is_connected():
print("ERROR: Cannot query - not connected")
return None
try:
with self._comm_lock:
# Clear input buffer
self._serial.reset_input_buffer()
# Send query
self._serial.write(command)
self._serial.flush()
# Read response (terminated by CR)
response = self._serial.read_until(b'\r')
if not response:
print("ERROR: No response from laser")
return None
return HeliosCommand.parse_response(response)
except serial.SerialException as e:
print(f"ERROR: Query failed: {e}")
return None
def _set_and_verify(self, set_cmd: bytes, query_cmd: bytes,
expected_value: str, retries: int = 3) -> bool:
"""
Set a value and verify it was set correctly
Args:
set_cmd: Command to set value
query_cmd: Command to query value
expected_value: Expected response
retries: Number of retry attempts
Returns:
bool: True if value was set and verified
"""
for attempt in range(retries):
# Send set command
if not self._send_command(set_cmd):
continue
time.sleep(0.5) # Wait 500ms for laser to process (per documentation)
# Query to verify
response = self._query(query_cmd)
if response and response == expected_value:
return True
if attempt < retries - 1:
print(f"DEBUG: Verification failed, retrying ({attempt + 1}/{retries})")
time.sleep(0.5)
print(f"ERROR: Failed to set and verify value after {retries} attempts")
return False
# ==================== Laser Control ====================
def set_laser_enable(self, enabled: bool) -> bool:
"""
Enable or disable laser
Args:
enabled: True to enable, False to disable
Returns:
bool: True if command successful
"""
print(f"DEBUG: {'Enabling' if enabled else 'Disabling'} laser")
cmd = self.protocol.cmd_set_laser_enable(enabled)
query_cmd = self.protocol.cmd_query_laser_enable()
expected = "1" if enabled else "0"
if self._set_and_verify(cmd, query_cmd, expected):
self._laser_enabled = enabled
return True
return False
def is_laser_enabled(self) -> bool:
"""Check if laser is currently enabled"""
return self._laser_enabled
def query_laser_enable(self) -> bool:
"""Query laser enable state from hardware"""
cmd = self.protocol.cmd_query_laser_enable()
response = self._query(cmd)
if response:
self._laser_enabled = (response == "1")
return self._laser_enabled
return False
# ==================== Pulse Mode Control ====================
def set_pulse_mode(self, mode: PulseMode) -> bool:
"""
Set pulse mode
Args:
mode: PulseMode enum value
Returns:
bool: True if command successful
"""
print(f"DEBUG: Setting pulse mode to {mode.name}")
cmd = self.protocol.cmd_set_pulse_mode(mode)
query_cmd = self.protocol.cmd_query_pulse_mode()
expected = str(mode.value)
if self._set_and_verify(cmd, query_cmd, expected):
self._pulse_mode = mode
return True
return False
def get_pulse_mode(self) -> PulseMode:
"""Get current pulse mode"""
return self._pulse_mode
# ==================== Frequency Control ====================
def set_frequency_hz(self, freq_hz: float) -> bool:
"""
Set laser frequency in Hz
Args:
freq_hz: Frequency in Hz (16.7 kHz to 125 kHz)
Returns:
bool: True if command successful
"""
print(f"DEBUG: Setting laser frequency to {freq_hz} Hz")
try:
cmd = self.protocol.cmd_set_frequency_hz(freq_hz)
period_ns = self.protocol.frequency_to_period_ns(freq_hz)
query_cmd = self.protocol.cmd_query_frequency()
expected = str(period_ns)
if self._set_and_verify(cmd, query_cmd, expected):
self._frequency_hz = freq_hz
return True
except ValueError as e:
print(f"ERROR: {e}")
return False
def get_frequency_hz(self) -> float:
"""Get current frequency in Hz"""
return self._frequency_hz
def query_frequency_hz(self) -> Optional[float]:
"""Query frequency from hardware (returns Hz)"""
cmd = self.protocol.cmd_query_frequency()
response = self._query(cmd)
if response:
try:
period_ns = int(response)
freq_hz = self.protocol.period_ns_to_frequency(period_ns)
self._frequency_hz = freq_hz
return freq_hz
except (ValueError, ZeroDivisionError) as e:
print(f"ERROR: Failed to parse frequency: {e}")
return None
# ==================== Current Control ====================
def set_current_ma(self, current_ma: float) -> bool:
"""
Set laser diode current in mA
Args:
current_ma: Current in mA (0-7000)
Returns:
bool: True if command successful
"""
print(f"DEBUG: Setting laser current to {current_ma} mA")
try:
cmd = self.protocol.cmd_set_current_ma(current_ma)
query_cmd = self.protocol.cmd_query_current()
expected = str(int(current_ma))
if self._set_and_verify(cmd, query_cmd, expected):
self._current_ma = current_ma
return True
except ValueError as e:
print(f"ERROR: {e}")
return False
def get_current_ma(self) -> float:
"""Get current setting in mA"""
return self._current_ma
# ==================== Temperature Monitoring ====================
def query_pump_temperature_c(self) -> Optional[float]:
"""Query pump diode temperature in °C"""
cmd = self.protocol.cmd_query_pump_temp()
response = self._query(cmd)
if response:
try:
temp_mc = int(response)
temp_c = self.protocol.millicelsius_to_celsius(temp_mc)
self._pump_temp_c = temp_c
return temp_c
except ValueError as e:
print(f"ERROR: Failed to parse temperature: {e}")
return None
def query_resonator_temperature_c(self) -> Optional[float]:
"""Query resonator/SHG temperature in °C"""
cmd = self.protocol.cmd_query_resonator_temp()
response = self._query(cmd)
if response:
try:
temp_mc = int(response)
temp_c = self.protocol.millicelsius_to_celsius(temp_mc)
self._resonator_temp_c = temp_c
return temp_c
except ValueError as e:
print(f"ERROR: Failed to parse temperature: {e}")
return None
def query_qswitch_temperature_c(self) -> Optional[float]:
"""Query q-switch temperature in °C"""
cmd = self.protocol.cmd_query_qswitch_temp()
response = self._query(cmd)
if response:
try:
temp_mc = int(response)
temp_c = self.protocol.millicelsius_to_celsius(temp_mc)
self._qswitch_temp_c = temp_c
return temp_c
except ValueError as e:
print(f"ERROR: Failed to parse temperature: {e}")
return None
def query_power_stage_temperature_c(self) -> Optional[float]:
"""Query controller power stage temperature in °C"""
cmd = self.protocol.cmd_query_power_stage_temp()
response = self._query(cmd)
if response:
try:
temp_mc = int(response)
temp_c = self.protocol.millicelsius_to_celsius(temp_mc)
self._power_stage_temp_c = temp_c
return temp_c
except ValueError as e:
print(f"ERROR: Failed to parse temperature: {e}")
return None
def query_all_temperatures(self) -> Dict[str, float]:
"""
Query all temperatures
Returns:
dict: Temperature readings in °C
"""
temps = {}
temps['pump'] = self.query_pump_temperature_c()
time.sleep(0.5)
temps['resonator'] = self.query_resonator_temperature_c()
time.sleep(0.5)
temps['qswitch'] = self.query_qswitch_temperature_c()
time.sleep(0.5)
temps['power_stage'] = self.query_power_stage_temperature_c()
return temps
# ==================== Status and Monitoring ====================
def query_status(self) -> HeliosStatus:
"""Query status register"""
cmd = self.protocol.cmd_query_status()
response = self._query(cmd)
if response:
try:
status_value = int(response)
self._status = HeliosStatus(status_value)
return self._status
except ValueError as e:
print(f"ERROR: Failed to parse status: {e}")
return self._status
def clear_status(self) -> bool:
"""Clear status register"""
cmd = self.protocol.cmd_clear_status()
return self._send_command(cmd)
def clear_errors(self) -> bool:
"""Clear controller errors"""
cmd = self.protocol.cmd_clear_errors()
return self._send_command(cmd)
def query_power_monitor_mw(self) -> Optional[float]:
"""Query laser power monitor in mW"""
cmd = self.protocol.cmd_query_power_monitor()
response = self._query(cmd)
if response:
try:
power_mw = float(response)
self._power_mw = power_mw
return power_mw
except ValueError as e:
print(f"ERROR: Failed to parse power: {e}")
return None
def query_operation_hours(self) -> Optional[float]:
"""Query laser diode operation time in hours"""
cmd = self.protocol.cmd_query_operation_time()
response = self._query(cmd)
if response:
try:
hours = float(response)
self._operation_hours = hours
return hours
except ValueError as e:
print(f"ERROR: Failed to parse operation time: {e}")
return None
# ==================== Serial Numbers ====================
def query_controller_serial(self) -> str:
"""Query controller serial number"""
cmd = self.protocol.cmd_query_controller_serial()
response = self._query(cmd)
if response:
self._controller_serial = response
return response
return ""
def query_head_serial(self) -> str:
"""Query laser head serial number"""
cmd = self.protocol.cmd_query_head_serial()
response = self._query(cmd)
if response:
self._head_serial = response
return response
return ""
def get_controller_serial(self) -> str:
"""Get cached controller serial number"""
return self._controller_serial
def get_head_serial(self) -> str:
"""Get cached head serial number"""
return self._head_serial
# ==================== Factory Reset ====================
def restore_factory_settings(self) -> bool:
"""
Restore factory settings
WARNING: This will reset all parameters to factory defaults.
Laser must be disabled before calling this method.
After calling, wait 2 seconds before power cycling.
Returns:
bool: True if command sent successfully
"""
if self._laser_enabled:
print("ERROR: Laser must be disabled before factory reset")
return False
print("WARNING: Restoring factory settings")
cmd = self.protocol.cmd_restore_factory()
if self._send_command(cmd):
print("INFO: Factory settings restored. Wait 2s before power cycle.")
time.sleep(2)
return True
return False
# ==================== State Management ====================
def _update_cached_state(self):
"""Update all cached state from hardware"""
self.query_laser_enable()
time.sleep(0.5)
self.query_frequency_hz()
time.sleep(0.5)
# Current is write-only in some modes, skip query
self.query_status()
def get_status(self) -> Dict:
"""
Get complete laser status
Returns:
dict: Comprehensive status dictionary
"""
return {
'connected': self.is_connected(),
'laser_enabled': self._laser_enabled,
'pulse_mode': self._pulse_mode.name,
'frequency_hz': self._frequency_hz,
'current_ma': self._current_ma,
'power_mw': self._power_mw,
'operation_hours': self._operation_hours,
'temperatures': {
'pump_c': self._pump_temp_c,
'resonator_c': self._resonator_temp_c,
'qswitch_c': self._qswitch_temp_c,
'power_stage_c': self._power_stage_temp_c
},
'status_value': self._status.value,
'has_errors': self._status.has_errors(),
'controller_serial': self._controller_serial,
'head_serial': self._head_serial
}
def update_status(self):
"""Update status information from hardware"""
if not self.is_connected():
return
self.query_status()
time.sleep(0.5)
self.query_power_monitor_mw()
time.sleep(0.5)
self.query_all_temperatures()
-302
View File
@@ -1,302 +0,0 @@
"""
Helios Laser Protocol Handler
ASCII-based RS-232 communication protocol for Helios laser systems
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
from enum import IntEnum
from typing import Union, Optional
class PulseMode(IntEnum):
"""Helios pulse mode settings"""
SINGLE_PULSE = 1
GATING = 4
CONTINUOUS_PULSING = 14
class HeliosCommand:
"""
Helios laser command constants and builders
All commands are ASCII strings terminated with carriage return <CR>
Format: COMMAND value<CR> for setting
COMMAND<CR> for querying
"""
# Command constants
LDO = "LDO" # Laser enabled (0/1)
LDG = "LDG" # Pulse mode (1/4/14)
LDF = "LDF" # Period between pulses (ns)
LRE = "LRE" # Laser remote enable (0/1) - Single electronic only
LDS = "LDS" # Laser diode pulse current (mA)
LTA = "LTA" # Actual pump diode temperature (m°C)
LMA = "LMA" # Actual resonator/SHG temperature (mA)
EOA = "EOA" # Actual q-switch temperature (m°C)
ELT = "ELT" # Pump diode temp control deviation (m°C)
ELM = "ELM" # Resonator/SHG temp control deviation (m°C)
EEO = "EEO" # Q-switch temp control deviation (m°C)
LTT = "LTT" # Controller power stage temperature (m°C)
LER = "LER" # Status register (read)
LCE = "LCE" # Clear status register
CCE = "CCE" # Clear controller errors
CSR = "CSR" # Controller serial number
HSR = "HSR" # Laser head serial number
HTR = "HTR" # Laser diode operation time (hours)
HPR = "HPR" # Restore factory settings
HMP = "HMP" # Laser power monitor (mW)
@staticmethod
def build_command(command: str, value: Optional[Union[int, float]] = None) -> bytes:
"""
Build a Helios command string
Args:
command: Command string (e.g., "LDO", "LDF")
value: Optional value to set (None for query)
Returns:
bytes: Command ready to send over serial
Example:
build_command("LDO", 1) -> b"LDO 1\r"
build_command("LDO") -> b"LDO\r"
"""
if value is not None:
cmd_str = f"{command} {value}\r"
else:
cmd_str = f"{command}\r"
return cmd_str.encode('ascii')
@staticmethod
def parse_response(response: bytes) -> str:
"""
Parse response from Helios laser
Args:
response: Raw bytes from serial port
Returns:
str: Parsed response string (stripped of CR/LF)
"""
return response.decode('ascii').strip()
class HeliosStatus:
"""
Helios status register decoder
Status is sum of multiple bit flags:
Example: 1*2^0 + 0*2^1 + 1*2^2 = 5
"""
# Status bit definitions (from LER/LCE/CCE commands)
# These are example flags - actual flags depend on controller model
# Refer to "Troubleshooting" section in manual for complete list
def __init__(self, status_value: int):
"""
Initialize status decoder
Args:
status_value: Numeric status value from controller
"""
self.value = status_value
self.flags = self._decode_flags(status_value)
def _decode_flags(self, value: int) -> list:
"""Decode status value into list of active bit positions"""
flags = []
bit_pos = 0
while value > 0:
if value & 1:
flags.append(bit_pos)
value >>= 1
bit_pos += 1
return flags
def has_errors(self) -> bool:
"""Check if any error flags are set"""
return self.value > 0
def __str__(self) -> str:
return f"Status: {self.value} (flags: {self.flags})"
class HeliosProtocol:
"""
High-level Helios protocol handler
Provides parameter validation and unit conversions
"""
# Parameter ranges (from protocol document)
RANGE_LDO = (0, 1)
RANGE_LDG = (0, 14)
RANGE_LDF = (8000, 60000) # ns
RANGE_LRE = (0, 1)
RANGE_LDS = (0, 7000) # mA
RANGE_LTA = (5000, 50000) # m°C
RANGE_LMA = (0, 4000) # mA (seems like error in doc, should be m°C)
RANGE_EOA = (5000, 50000) # m°C
RANGE_ELT = (-32768, 32767) # m°C
RANGE_ELM = (-32768, 32767) # m°C
RANGE_EEO = (-32768, 32767) # m°C
RANGE_LTT = (5000, 65535) # m°C
RANGE_HTR = (0, 65535) # hours
RANGE_HMP = (0, 5000) # mW
def __init__(self):
"""Initialize protocol handler"""
pass
# Temperature conversions (m°C <-> °C)
@staticmethod
def celsius_to_millicelsius(temp_c: float) -> int:
"""Convert temperature from °C to m°C (milli-Celsius)"""
return int(temp_c * 1000)
@staticmethod
def millicelsius_to_celsius(temp_mc: int) -> float:
"""Convert temperature from m°C to °C"""
return temp_mc / 1000.0
# Frequency conversions (Hz <-> ns period)
@staticmethod
def frequency_to_period_ns(freq_hz: float) -> int:
"""
Convert frequency in Hz to period in nanoseconds
Args:
freq_hz: Frequency in Hz
Returns:
int: Period in nanoseconds
Example:
50000 Hz -> 20000 ns (50 kHz)
"""
if freq_hz <= 0:
raise ValueError("Frequency must be positive")
period_ns = int(1e9 / freq_hz)
return period_ns
@staticmethod
def period_ns_to_frequency(period_ns: int) -> float:
"""
Convert period in nanoseconds to frequency in Hz
Args:
period_ns: Period in nanoseconds
Returns:
float: Frequency in Hz
"""
if period_ns <= 0:
raise ValueError("Period must be positive")
freq_hz = 1e9 / period_ns
return freq_hz
# Command builders with validation
def cmd_set_laser_enable(self, enabled: bool) -> bytes:
"""Build command to enable/disable laser"""
value = 1 if enabled else 0
return HeliosCommand.build_command(HeliosCommand.LDO, value)
def cmd_query_laser_enable(self) -> bytes:
"""Build query for laser enable state"""
return HeliosCommand.build_command(HeliosCommand.LDO)
def cmd_set_pulse_mode(self, mode: PulseMode) -> bytes:
"""Build command to set pulse mode"""
if mode not in [PulseMode.SINGLE_PULSE, PulseMode.GATING,
PulseMode.CONTINUOUS_PULSING]:
raise ValueError(f"Invalid pulse mode: {mode}")
return HeliosCommand.build_command(HeliosCommand.LDG, mode)
def cmd_query_pulse_mode(self) -> bytes:
"""Build query for pulse mode"""
return HeliosCommand.build_command(HeliosCommand.LDG)
def cmd_set_frequency_hz(self, freq_hz: float) -> bytes:
"""
Build command to set laser frequency (Hz)
Converts to period in ns internally
"""
period_ns = self.frequency_to_period_ns(freq_hz)
if not (self.RANGE_LDF[0] <= period_ns <= self.RANGE_LDF[1]):
raise ValueError(f"Frequency results in period {period_ns}ns, "
f"valid range: {self.RANGE_LDF[0]}-{self.RANGE_LDF[1]}ns")
return HeliosCommand.build_command(HeliosCommand.LDF, period_ns)
def cmd_query_frequency(self) -> bytes:
"""Build query for laser frequency (returns period in ns)"""
return HeliosCommand.build_command(HeliosCommand.LDF)
def cmd_set_current_ma(self, current_ma: float) -> bytes:
"""Build command to set laser diode current (mA)"""
if not (self.RANGE_LDS[0] <= current_ma <= self.RANGE_LDS[1]):
raise ValueError(f"Current {current_ma}mA outside valid range: "
f"{self.RANGE_LDS[0]}-{self.RANGE_LDS[1]}mA")
return HeliosCommand.build_command(HeliosCommand.LDS, int(current_ma))
def cmd_query_current(self) -> bytes:
"""Build query for laser diode current"""
return HeliosCommand.build_command(HeliosCommand.LDS)
def cmd_query_pump_temp(self) -> bytes:
"""Build query for actual pump diode temperature"""
return HeliosCommand.build_command(HeliosCommand.LTA)
def cmd_query_resonator_temp(self) -> bytes:
"""Build query for actual resonator/SHG temperature"""
return HeliosCommand.build_command(HeliosCommand.LMA)
def cmd_query_qswitch_temp(self) -> bytes:
"""Build query for actual q-switch temperature"""
return HeliosCommand.build_command(HeliosCommand.EOA)
def cmd_query_power_stage_temp(self) -> bytes:
"""Build query for controller power stage temperature"""
return HeliosCommand.build_command(HeliosCommand.LTT)
def cmd_query_status(self) -> bytes:
"""Build query for status register"""
return HeliosCommand.build_command(HeliosCommand.LER)
def cmd_clear_status(self) -> bytes:
"""Build command to clear status register"""
return HeliosCommand.build_command(HeliosCommand.LCE, 0)
def cmd_clear_errors(self) -> bytes:
"""Build command to clear controller errors"""
return HeliosCommand.build_command(HeliosCommand.CCE, 0)
def cmd_query_controller_serial(self) -> bytes:
"""Build query for controller serial number"""
return HeliosCommand.build_command(HeliosCommand.CSR)
def cmd_query_head_serial(self) -> bytes:
"""Build query for laser head serial number"""
return HeliosCommand.build_command(HeliosCommand.HSR)
def cmd_query_operation_time(self) -> bytes:
"""Build query for laser diode operation time (hours)"""
return HeliosCommand.build_command(HeliosCommand.HTR)
def cmd_query_power_monitor(self) -> bytes:
"""Build query for laser power monitor (mW)"""
return HeliosCommand.build_command(HeliosCommand.HMP)
def cmd_restore_factory(self) -> bytes:
"""
Build command to restore factory settings
WARNING: Laser must be disabled (LDO 0) before this command
After sending, wait 2 seconds before rebooting/power cycling
"""
return HeliosCommand.build_command(HeliosCommand.HPR)
-387
View File
@@ -1,387 +0,0 @@
"""
Microscope Controller
Handles communication with Genesis and Helios microscope/laser systems
Genesis: Laser scanning microscope system (stub)
Helios: Laser control system with frequency and current control (full implementation)
Both systems communicate via USB/Serial interfaces
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import time
from typing import Dict, Optional
from hardware.helios_driver import HeliosDriver, PulseMode
class MicroscopeController:
"""
Controller for Genesis and Helios microscope systems
Provides methods for:
- System connection and initialization
- Interlock status monitoring
- Parameter configuration
- Safety checks
"""
def __init__(self):
"""Initialize microscope controller"""
# Genesis state (stub)
self._genesis_connected = False
self._genesis_ready = False
self._genesis_interlocked = False
self._genesis_power_mw = 0.0
# Helios driver (full implementation)
self._helios_driver = HeliosDriver()
self._helios_port = None
# System interlock (master safety)
self._system_interlocked = False
print("INFO: Microscope controller initialized")
print(" - Genesis: Stub implementation")
print(" - Helios: Full RS-232 driver")
# ==================== Genesis Methods ====================
def connect_genesis(self) -> bool:
"""
Connect to Genesis laser scanning microscope
Returns:
bool: True if connection successful
"""
print("DEBUG: Connecting to Genesis microscope")
# Stub implementation
time.sleep(0.1)
self._genesis_connected = True
self._genesis_ready = True
self._genesis_interlocked = True # Assume interlocks OK
print("INFO: Genesis microscope connected")
return True
def disconnect_genesis(self) -> bool:
"""
Disconnect from Genesis microscope
Returns:
bool: True if disconnection successful
"""
print("DEBUG: Disconnecting from Genesis microscope")
self._genesis_connected = False
self._genesis_ready = False
print("INFO: Genesis microscope disconnected")
return True
def apply_genesis_settings(self, settings: Dict) -> bool:
"""
Apply Genesis configuration settings
Args:
settings: Dictionary containing Genesis parameters
Returns:
bool: True if settings applied successfully
"""
print("DEBUG: Applying Genesis settings")
print(f" Power: {settings.get('power_mw', 0)} mW")
if not self._genesis_connected:
print("ERROR: Genesis not connected")
return False
self._genesis_power_mw = settings.get('power_mw', 0.0)
# Stub - would send commands to actual hardware
time.sleep(0.05)
print("INFO: Genesis settings applied")
return True
def get_genesis_status(self) -> Dict[str, any]:
"""
Get Genesis microscope status
Returns:
dict: Genesis status information
"""
return {
'connected': self._genesis_connected,
'ready': self._genesis_ready,
'interlocked': self._genesis_interlocked,
'power_mw': self._genesis_power_mw
}
# ==================== Helios Methods ====================
def connect_helios(self, port: str) -> bool:
"""
Connect to Helios laser system
Args:
port: COM port for Helios device
Returns:
bool: True if connection successful
"""
print(f"DEBUG: Connecting to Helios on {port}")
if not self._helios_driver.connect(port):
return False
self._helios_port = port
print(f"INFO: Helios connected on {port}")
print(f" Controller S/N: {self._helios_driver.get_controller_serial()}")
print(f" Head S/N: {self._helios_driver.get_head_serial()}")
return True
def disconnect_helios(self) -> bool:
"""
Disconnect from Helios laser system
Returns:
bool: True if disconnection successful
"""
print("DEBUG: Disconnecting from Helios")
return self._helios_driver.disconnect()
def is_helios_connected(self) -> bool:
"""Check if Helios is connected"""
return self._helios_driver.is_connected()
def apply_helios_settings(self, settings: Dict) -> bool:
"""
Apply Helios configuration settings
This method connects and configures the Helios laser with the
specified parameters from the settings dialog.
Args:
settings: Dictionary containing:
- com_port: COM port name
- frequency_hz: Laser frequency in Hz
- current_ma: Laser diode current in mA
Returns:
bool: True if settings applied successfully
"""
if settings is None:
print("ERROR: No settings provided")
return False
print("DEBUG: Applying Helios settings")
print(f" Port: {settings.get('com_port', 'N/A')}")
print(f" Frequency: {settings.get('frequency_hz', 0)} Hz")
print(f" Current: {settings.get('current_ma', 0)} mA")
# Connect if not already connected or port changed
port = settings.get('com_port')
if not port:
print("ERROR: No COM port specified")
return False
if not self.is_helios_connected() or port != self._helios_port:
if self.is_helios_connected():
self.disconnect_helios()
if not self.connect_helios(port):
return False
# Set frequency
freq_hz = settings.get('frequency_hz', 0.0)
if freq_hz > 0:
if not self._helios_driver.set_frequency_hz(freq_hz):
print("ERROR: Failed to set frequency")
return False
time.sleep(0.05)
# Set current
current_ma = settings.get('current_ma', 0.0)
if current_ma > 0:
if not self._helios_driver.set_current_ma(current_ma):
print("ERROR: Failed to set current")
return False
time.sleep(0.05)
# Set to continuous pulsing mode by default
if not self._helios_driver.set_pulse_mode(PulseMode.CONTINUOUS_PULSING):
print("WARNING: Failed to set pulse mode")
print("INFO: Helios settings applied successfully")
return True
def helios_enable_laser(self, enabled: bool) -> bool:
"""
Enable or disable Helios laser
Args:
enabled: True to enable, False to disable
Returns:
bool: True if command successful
"""
if not self.is_helios_connected():
print("ERROR: Helios not connected")
return False
return self._helios_driver.set_laser_enable(enabled)
def is_helios_laser_enabled(self) -> bool:
"""Check if Helios laser is currently enabled"""
if not self.is_helios_connected():
return False
return self._helios_driver.is_laser_enabled()
def get_helios_status(self) -> Dict[str, any]:
"""
Get Helios laser system status
Returns:
dict: Helios status information
"""
if not self.is_helios_connected():
return {
'connected': False,
'ready': False,
'interlocked': False,
'port': None,
'frequency_hz': 0.0,
'current_ma': 0.0,
'laser_enabled': False,
'power_mw': 0.0
}
# Get comprehensive status from driver
status = self._helios_driver.get_status()
return {
'connected': status['connected'],
'ready': not status['has_errors'],
'interlocked': not status['has_errors'], # Use error state as interlock
'port': self._helios_port,
'frequency_hz': status['frequency_hz'],
'current_ma': status['current_ma'],
'laser_enabled': status['laser_enabled'],
'power_mw': status['power_mw'],
'operation_hours': status['operation_hours'],
'controller_serial': status['controller_serial'],
'head_serial': status['head_serial']
}
def helios_update_status(self):
"""Update Helios status from hardware"""
if self.is_helios_connected():
self._helios_driver.update_status()
# ==================== Combined Status Methods ====================
def get_status(self) -> Dict[str, any]:
"""
Get complete microscope system status
Returns:
dict: Combined status for Genesis, Helios, and interlocks
"""
# Get Helios status from driver
helios_status = self.get_helios_status()
helios_ready = helios_status.get('ready', False)
helios_interlocked = helios_status.get('interlocked', False)
return {
# System-wide
'interlocked': self._system_interlocked or (
self._genesis_interlocked and helios_interlocked
),
# Genesis
'genesis_ready': self._genesis_ready,
'genesis_interlocked': self._genesis_interlocked,
# Helios
'helios_ready': helios_ready,
'helios_interlocked': helios_interlocked
}
def check_interlocks(self) -> bool:
"""
Check all safety interlocks
Returns:
bool: True if all interlocks are satisfied
"""
# Check Genesis interlocks
if self._genesis_connected and not self._genesis_interlocked:
print("WARNING: Genesis interlock not satisfied")
return False
# Check Helios interlocks
if self.is_helios_connected():
helios_status = self.get_helios_status()
if not helios_status.get('interlocked', False):
print("WARNING: Helios interlock not satisfied")
return False
return True
# ==================== Scan Preparation ====================
def prepare_for_scan(self, params: Dict) -> bool:
"""
Prepare microscope systems for scanning
Args:
params: Scan parameters dictionary
Returns:
bool: True if preparation successful
"""
print("DEBUG: Preparing microscope systems for scan")
# Check interlocks
if not self.check_interlocks():
print("ERROR: Interlock check failed")
return False
# Verify systems are ready
if self._genesis_connected and not self._genesis_ready:
print("ERROR: Genesis not ready")
return False
if self.is_helios_connected():
helios_status = self.get_helios_status()
if not helios_status.get('ready', False):
print("ERROR: Helios not ready")
return False
# Configure for scan
# Stub implementation
time.sleep(0.1)
print("INFO: Microscope systems ready for scan")
return True
def emergency_stop(self) -> bool:
"""
Emergency stop all microscope operations
Returns:
bool: True if stop successful
"""
print("WARNING: Emergency stop triggered")
# Disable all systems
if self._genesis_connected:
self._genesis_ready = False
if self.is_helios_connected():
# Disable Helios laser immediately
self._helios_driver.set_laser_enable(False)
return True
-244
View File
@@ -1,244 +0,0 @@
"""
ThorLabs Stage Settings Management
Handles saving and loading of stage configuration parameters
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import json
import os
from typing import Dict, Optional
from pathlib import Path
class StageSettings:
"""
Manages stage configuration settings including velocity, acceleration,
and trigger I/O configuration
"""
DEFAULT_SETTINGS = {
'velocity': {
'x_axis': 1.0, # mm/s
'y_axis': 1.0, # mm/s
'z_axis': 1.0, # mm/s
},
'acceleration': {
'x_axis': 5.0, # mm/s²
'y_axis': 5.0, # mm/s²
'z_axis': 5.0, # mm/s²
},
'trigger': {
'x_axis': {
'mode': 0x00, # Disabled
'polarity': 0x01, # Active high
'start_pos_fwd': 0.0,
'start_pos_rev': 0.0,
'interval_fwd': 0.0,
'interval_rev': 0.0
},
'y_axis': {
'mode': 0x00, # Disabled
'polarity': 0x01, # Active high
'start_pos_fwd': 0.0,
'start_pos_rev': 0.0,
'interval_fwd': 0.0,
'interval_rev': 0.0
},
'z_axis': {
'mode': 0x00, # Disabled
'polarity': 0x01, # Active high
'start_pos_fwd': 0.0,
'start_pos_rev': 0.0,
'interval_fwd': 0.0,
'interval_rev': 0.0
}
},
'digital_io': {
'output_bits': 0x00
}
}
def __init__(self, settings_file: Optional[str] = None):
"""
Initialize stage settings manager
Args:
settings_file: Path to settings file (default: ~/.nuescan/stage_settings.json)
"""
if settings_file is None:
# Default to user home directory
home = Path.home()
settings_dir = home / '.nuescan'
settings_dir.mkdir(exist_ok=True)
settings_file = str(settings_dir / 'stage_settings.json')
self.settings_file = settings_file
self.settings = self.DEFAULT_SETTINGS.copy()
# Load existing settings if available
self.load()
def load(self) -> bool:
"""
Load settings from file
Returns:
bool: True if settings loaded successfully, False otherwise
"""
if not os.path.exists(self.settings_file):
print(f"INFO: Settings file not found, using defaults: {self.settings_file}")
return False
try:
with open(self.settings_file, 'r') as f:
loaded_settings = json.load(f)
# Merge with defaults to ensure all keys exist
self._merge_settings(loaded_settings)
print(f"INFO: Loaded stage settings from {self.settings_file}")
return True
except Exception as e:
print(f"ERROR: Failed to load settings from {self.settings_file}: {e}")
return False
def save(self) -> bool:
"""
Save settings to file
Returns:
bool: True if settings saved successfully, False otherwise
"""
try:
# Ensure directory exists
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
with open(self.settings_file, 'w') as f:
json.dump(self.settings, f, indent=4)
print(f"INFO: Saved stage settings to {self.settings_file}")
return True
except Exception as e:
print(f"ERROR: Failed to save settings to {self.settings_file}: {e}")
return False
def _merge_settings(self, loaded_settings: Dict):
"""Merge loaded settings with defaults"""
# Velocity
if 'velocity' in loaded_settings:
self.settings['velocity'].update(loaded_settings['velocity'])
# Acceleration
if 'acceleration' in loaded_settings:
self.settings['acceleration'].update(loaded_settings['acceleration'])
# Trigger
if 'trigger' in loaded_settings:
for axis in ['x_axis', 'y_axis', 'z_axis']:
if axis in loaded_settings['trigger']:
self.settings['trigger'][axis].update(loaded_settings['trigger'][axis])
# Digital I/O
if 'digital_io' in loaded_settings:
self.settings['digital_io'].update(loaded_settings['digital_io'])
# ==================== Velocity Settings ====================
def get_velocity(self, axis: str) -> float:
"""Get velocity for specific axis (x_axis, y_axis, z_axis)"""
return self.settings['velocity'].get(axis, 1.0)
def set_velocity(self, axis: str, velocity: float):
"""Set velocity for specific axis"""
if axis in ['x_axis', 'y_axis', 'z_axis']:
self.settings['velocity'][axis] = velocity
def get_all_velocities(self) -> Dict[str, float]:
"""Get all axis velocities"""
return self.settings['velocity'].copy()
def set_all_velocities(self, x: float, y: float, z: float):
"""Set all axis velocities"""
self.settings['velocity']['x_axis'] = x
self.settings['velocity']['y_axis'] = y
self.settings['velocity']['z_axis'] = z
# ==================== Acceleration Settings ====================
def get_acceleration(self, axis: str) -> float:
"""Get acceleration for specific axis"""
return self.settings['acceleration'].get(axis, 5.0)
def set_acceleration(self, axis: str, acceleration: float):
"""Set acceleration for specific axis"""
if axis in ['x_axis', 'y_axis', 'z_axis']:
self.settings['acceleration'][axis] = acceleration
def get_all_accelerations(self) -> Dict[str, float]:
"""Get all axis accelerations"""
return self.settings['acceleration'].copy()
def set_all_accelerations(self, x: float, y: float, z: float):
"""Set all axis accelerations"""
self.settings['acceleration']['x_axis'] = x
self.settings['acceleration']['y_axis'] = y
self.settings['acceleration']['z_axis'] = z
# ==================== Trigger Settings ====================
def get_trigger_config(self, axis: str) -> Dict:
"""Get trigger configuration for specific axis"""
return self.settings['trigger'].get(axis, {}).copy()
def set_trigger_config(self, axis: str, mode: int, polarity: int = 0x01,
start_pos_fwd: float = 0.0, start_pos_rev: float = 0.0,
interval_fwd: float = 0.0, interval_rev: float = 0.0):
"""Set trigger configuration for specific axis"""
if axis in ['x_axis', 'y_axis', 'z_axis']:
self.settings['trigger'][axis] = {
'mode': mode,
'polarity': polarity,
'start_pos_fwd': start_pos_fwd,
'start_pos_rev': start_pos_rev,
'interval_fwd': interval_fwd,
'interval_rev': interval_rev
}
def get_trigger_mode(self, axis: str) -> int:
"""Get trigger mode for specific axis"""
return self.settings['trigger'].get(axis, {}).get('mode', 0x00)
def set_trigger_mode(self, axis: str, mode: int):
"""Set trigger mode for specific axis"""
if axis in ['x_axis', 'y_axis', 'z_axis']:
if axis not in self.settings['trigger']:
self.settings['trigger'][axis] = self.DEFAULT_SETTINGS['trigger'][axis].copy()
self.settings['trigger'][axis]['mode'] = mode
# ==================== Digital I/O Settings ====================
def get_digital_outputs(self) -> int:
"""Get digital output bits"""
return self.settings['digital_io'].get('output_bits', 0x00)
def set_digital_outputs(self, output_bits: int):
"""Set digital output bits"""
self.settings['digital_io']['output_bits'] = output_bits
# ==================== Utility Methods ====================
def reset_to_defaults(self):
"""Reset all settings to defaults"""
self.settings = self.DEFAULT_SETTINGS.copy()
def get_all_settings(self) -> Dict:
"""Get copy of all settings"""
return json.loads(json.dumps(self.settings)) # Deep copy via JSON
def update_from_dict(self, settings_dict: Dict):
"""Update settings from dictionary"""
self._merge_settings(settings_dict)
-259
View File
@@ -1,259 +0,0 @@
"""
T3R-SL Device Controller
Handles communication with T3R-SL device via USB/Serial
The T3R-SL is a specialized instrument control device that provides
timing, triggering, and coordination for the SRAS scanning system.
"""
import time
from typing import Dict, List, Optional
class T3RDevice:
"""
Controller for T3R-SL device
Provides methods for:
- Connecting/disconnecting
- Device initialization and homing
- Status monitoring
- Trigger and timing control
"""
def __init__(self):
"""Initialize T3R device controller"""
self._connected = False
self._port = None
self._homed = False
self._ready = False
# Device state
self._initialized = False
self._error_state = False
print("INFO: T3R-SL Device controller initialized (stub)")
def get_available_ports(self) -> List[str]:
"""
Get list of available COM ports
Returns:
list: Available port names
"""
# Stub implementation - return dummy ports
# In production, would scan for actual serial ports
return [
"COM1", "COM2", "COM3", "COM4", "COM5",
"/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyUSB2"
]
def connect(self, port: str) -> bool:
"""
Connect to T3R device on specified port
Args:
port: COM port name
Returns:
bool: True if connection successful
"""
print(f"DEBUG: Connecting to T3R device on {port}")
# Stub implementation
time.sleep(0.1)
self._port = port
self._connected = True
self._ready = False # Need to initialize after connect
print(f"INFO: Connected to T3R device on {port}")
# Auto-initialize
return self._initialize()
def disconnect(self) -> bool:
"""
Disconnect from T3R device
Returns:
bool: True if disconnection successful
"""
print("DEBUG: Disconnecting from T3R device")
self._connected = False
self._homed = False
self._ready = False
self._initialized = False
print("INFO: Disconnected from T3R device")
return True
def is_connected(self) -> bool:
"""Check if device is connected"""
return self._connected
def _initialize(self) -> bool:
"""
Initialize T3R device after connection
Returns:
bool: True if initialization successful
"""
print("DEBUG: Initializing T3R device")
if not self._connected:
print("ERROR: Cannot initialize - device not connected")
return False
# Stub - simulate initialization
time.sleep(0.2)
self._initialized = True
self._error_state = False
# Perform homing
return self.home()
def home(self) -> bool:
"""
Home/zero T3R device
Returns:
bool: True if homing successful
"""
print("DEBUG: Homing T3R device")
if not self._connected or not self._initialized:
print("ERROR: Cannot home - device not initialized")
return False
# Stub - simulate homing
time.sleep(0.3)
self._homed = True
self._ready = True
print("INFO: T3R device homed successfully")
return True
def get_status(self) -> Dict[str, bool]:
"""
Get current device status
Returns:
dict: Status information
"""
return {
'connected': self._connected,
'initialized': self._initialized,
'homed': self._homed,
'ready': self._ready,
'error': self._error_state
}
def prepare_for_scan(self, params: Dict) -> bool:
"""
Prepare T3R device for scanning operation
Args:
params: Scan parameters dictionary
Returns:
bool: True if preparation successful
"""
print("DEBUG: Preparing T3R device for scan")
print(f" Number of scans: {params.get('num_scans', 1)}")
print(f" Trigger voltage: {params.get('trigger_voltage', 0)}V")
if not self._ready:
print("ERROR: T3R device not ready for scanning")
return False
# Configure device for scan parameters
# Stub implementation
time.sleep(0.1)
print("INFO: T3R device ready for scanning")
return True
def trigger_acquisition(self) -> bool:
"""
Trigger a data acquisition event
Returns:
bool: True if trigger successful
"""
if not self._ready:
print("ERROR: Cannot trigger - device not ready")
return False
print("DEBUG: Triggering acquisition")
# Stub - would send trigger command
return True
def read_position(self) -> Optional[float]:
"""
Read current position from T3R device
Returns:
float: Current position value, or None if error
"""
if not self._ready:
return None
# Stub - return dummy position
return 0.0
def set_timing_parameters(self, acquisition_time: float,
delay_time: float) -> bool:
"""
Set timing parameters for acquisition
Args:
acquisition_time: Acquisition window time in seconds
delay_time: Delay before acquisition in seconds
Returns:
bool: True if parameters set successfully
"""
print(f"DEBUG: Setting timing - acq: {acquisition_time}s, delay: {delay_time}s")
if not self._connected:
print("ERROR: Device not connected")
return False
# Stub implementation
return True
def get_error_status(self) -> Dict[str, any]:
"""
Get detailed error status
Returns:
dict: Error status information
"""
return {
'has_error': self._error_state,
'error_code': 0,
'error_message': 'No error'
}
def reset(self) -> bool:
"""
Reset T3R device to initial state
Returns:
bool: True if reset successful
"""
print("DEBUG: Resetting T3R device")
if not self._connected:
return False
self._error_state = False
self._homed = False
self._ready = False
# Re-initialize
return self._initialize()
-651
View File
@@ -1,651 +0,0 @@
"""
ThorLabs MLS Stage Controller
Handles communication with ThorLabs MLS 3-axis positioning stage via BBD203 motor controller
The ThorLabs MLS stage provides precision X/Y/Z positioning for scanning operations.
This implementation uses the BBD203 3-channel motor controller with the APT protocol.
Channel Mapping:
- Channel 1: X-axis
- Channel 2: Y-axis
- Channel 3: Z-axis (optional)
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import time
from typing import Dict, Optional
from hardware.bbd203_driver import BBD203Driver
from hardware.bbd203_protocol import TriggerMode
from hardware.stage_settings import StageSettings
class ThorLabsStage:
"""
Controller for ThorLabs MLS positioning stage using BBD203 motor controller
Provides methods for:
- Connecting/disconnecting from stage
- Homing axes
- Position control and readout
- Status monitoring
"""
# Channel mapping
X_AXIS = 1
Y_AXIS = 2
Z_AXIS = 3
def __init__(self, encoder_counts_per_mm: int = 20000, settings_file: Optional[str] = None):
"""
Initialize ThorLabs stage controller
Args:
encoder_counts_per_mm: Encoder resolution (default: 20000 for MLS203)
settings_file: Path to settings file (default: ~/.nuescan/stage_settings.json)
"""
self._driver = BBD203Driver(encoder_counts_per_mm)
self._port = None
# Settings manager
self.settings = StageSettings(settings_file)
# Scanning state
self._scanning = False
# Default velocity and acceleration (can be overridden by settings)
self._default_velocity = 1.0 # mm/s
self._default_accel = 5.0 # mm/s²
print("INFO: ThorLabs Stage controller initialized (BBD203 driver)")
def connect(self, serial_number: str, baudrate: int = 115200) -> bool:
"""
Connect to ThorLabs stage via BBD203 controller using serial number
The serial number is printed on the BBD203 controller (e.g., '83123456').
The driver will automatically find the USB device and connect.
Args:
serial_number: BBD203 device serial number
baudrate: Baud rate (default: 115200)
Returns:
bool: True if connection successful
"""
print(f"DEBUG: Connecting to BBD203/MLS Stage with serial number {serial_number}")
# Connect by serial number - driver will auto-find the port
if not self._driver.connect_by_serial(serial_number, baudrate):
return False
self._port = serial_number # Store serial for reference
# Enable all channels
time.sleep(0.2)
self._driver.enable_channel(self.X_AXIS, True)
time.sleep(0.1)
self._driver.enable_channel(self.Y_AXIS, True)
time.sleep(0.1)
self._driver.enable_channel(self.Z_AXIS, True)
time.sleep(0.1)
# Apply startup settings from configuration
self.apply_startup_settings()
print("INFO: Stage connected and channels enabled")
return True
def disconnect(self) -> bool:
"""
Disconnect from ThorLabs stage
Returns:
bool: True if disconnection successful
"""
self._scanning = False
return self._driver.disconnect()
def is_connected(self) -> bool:
"""Check if stage is connected"""
return self._driver.is_connected()
# ==================== Homing ====================
def home_all_axes(self, wait: bool = True, timeout: float = 60.0) -> bool:
"""
Home all axes (X, Y, Z)
Args:
wait: If True, block until homing complete
timeout: Timeout in seconds
Returns:
bool: True if homing successful
"""
print("DEBUG: Homing all axes")
if not self.is_connected():
print("ERROR: Cannot home - stage not connected")
return False
return self._driver.home_all_channels(wait=wait, timeout=timeout)
def home_axis(self, axis: str, wait: bool = True, timeout: float = 30.0) -> bool:
"""
Home a specific axis
Args:
axis: Axis to home ('X', 'Y', or 'Z')
wait: If True, block until homing complete
timeout: Timeout in seconds
Returns:
bool: True if homing successful
"""
axis = axis.upper()
if axis not in ['X', 'Y', 'Z']:
print(f"ERROR: Invalid axis: {axis}")
return False
channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis]
print(f"DEBUG: Homing {axis} axis (channel {channel})")
return self._driver.home_channel(channel, wait=wait, timeout=timeout)
# ==================== Motion Control ====================
def move_absolute(self, x: Optional[float] = None,
y: Optional[float] = None,
z: Optional[float] = None,
wait: bool = False) -> bool:
"""
Move to absolute position
Args:
x: X position in mm (None to leave unchanged)
y: Y position in mm (None to leave unchanged)
z: Z position in mm (None to leave unchanged)
wait: If True, block until move complete
Returns:
bool: True if move successful
"""
if not self.is_connected():
print("ERROR: Stage not connected")
return False
success = True
# Move each axis that was specified
if x is not None:
if not self._driver.move_absolute(self.X_AXIS, x, wait=wait):
success = False
if y is not None:
if not self._driver.move_absolute(self.Y_AXIS, y, wait=wait):
success = False
if z is not None:
if not self._driver.move_absolute(self.Z_AXIS, z, wait=wait):
success = False
return success
def move_relative(self, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0,
wait: bool = False) -> bool:
"""
Move relative to current position
Args:
dx: X displacement in mm
dy: Y displacement in mm
dz: Z displacement in mm
wait: If True, block until move complete
Returns:
bool: True if move successful
"""
if not self.is_connected():
print("ERROR: Stage not connected")
return False
success = True
if dx != 0.0:
if not self._driver.move_relative(self.X_AXIS, dx, wait=wait):
success = False
if dy != 0.0:
if not self._driver.move_relative(self.Y_AXIS, dy, wait=wait):
success = False
if dz != 0.0:
if not self._driver.move_relative(self.Z_AXIS, dz, wait=wait):
success = False
return success
def stop_all(self, immediate: bool = True) -> bool:
"""
Stop all motion
Args:
immediate: If True, stop immediately; if False, decelerate
Returns:
bool: True if stop successful
"""
return self._driver.stop(0, immediate) # Channel 0 = all channels
# ==================== Position and Status ====================
def get_position(self) -> Dict[str, float]:
"""
Get current position
Returns:
dict: Current X, Y, Z positions in mm
"""
return {
'x': self._driver.get_position(self.X_AXIS) or 0.0,
'y': self._driver.get_position(self.Y_AXIS) or 0.0,
'z': self._driver.get_position(self.Z_AXIS) or 0.0
}
def get_status(self) -> Dict[str, bool]:
"""
Get current stage status
Returns:
dict: Status information compatible with main_window expectations
"""
x_status = self._driver.get_channel_status(self.X_AXIS) or {}
y_status = self._driver.get_channel_status(self.Y_AXIS) or {}
z_status = self._driver.get_channel_status(self.Z_AXIS) or {}
# Determine if any axis is moving
moving = (x_status.get('moving', False) or
y_status.get('moving', False) or
z_status.get('moving', False))
# Determine if stage is ready (all enabled axes are homed)
x_ready = x_status.get('enabled', False) and x_status.get('homed', False)
y_ready = y_status.get('enabled', False) and y_status.get('homed', False)
ready = x_ready and y_ready # Z is optional
return {
'connected': self.is_connected(),
'x_homed': x_status.get('homed', False),
'y_homed': y_status.get('homed', False),
'z_homed': z_status.get('homed', False),
'ready': ready,
'scanning': self._scanning,
'moving': moving
}
# ==================== Velocity Control ====================
def set_velocity(self, velocity_mm_s: float, accel_mm_s2: float,
axis: Optional[str] = None) -> bool:
"""
Set velocity and acceleration parameters
Args:
velocity_mm_s: Maximum velocity in mm/s
accel_mm_s2: Acceleration in mm/s²
axis: Specific axis ('X', 'Y', 'Z'), or None for all axes
Returns:
bool: True if parameters set successfully
"""
if axis:
axis = axis.upper()
if axis not in ['X', 'Y', 'Z']:
print(f"ERROR: Invalid axis: {axis}")
return False
channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis]
return self._driver.set_velocity_params(channel, velocity_mm_s, accel_mm_s2)
else:
# Set for all axes
success = True
for channel in [self.X_AXIS, self.Y_AXIS, self.Z_AXIS]:
if not self._driver.set_velocity_params(channel, velocity_mm_s, accel_mm_s2):
success = False
time.sleep(0.05)
return success
# ==================== Scan Support ====================
def prepare_for_scan(self, params: Dict) -> bool:
"""
Prepare stage for scanning operation
Args:
params: Scan parameters dictionary
Returns:
bool: True if preparation successful
"""
print("DEBUG: Preparing stage for scan")
status = self.get_status()
if not status['ready']:
print("ERROR: Stage not ready for scanning")
return False
# Move to start position
x_start = params.get('x_start', 0)
y_start = params.get('y_start', 0)
print(f" Moving to scan start: X={x_start} mm, Y={y_start} mm")
if not self.move_absolute(x=x_start, y=y_start, wait=True):
print("ERROR: Failed to move to start position")
return False
self._scanning = True
print("INFO: Stage ready for scanning")
return True
def stop_scan(self) -> bool:
"""
Stop current scan operation
Returns:
bool: True if stop successful
"""
print("DEBUG: Stopping scan")
self._scanning = False
return self.stop_all(immediate=True)
# ==================== Utility Methods ====================
def identify(self, channel: Optional[int] = None) -> bool:
"""
Flash front panel LEDs to identify controller
Args:
channel: Specific channel (1, 2, 3), or None for all
Returns:
bool: True if command sent successfully
"""
if channel is None:
# Identify all channels
for ch in [1, 2, 3]:
self._driver.identify(ch)
time.sleep(0.1)
return True
else:
return self._driver.identify(channel)
@staticmethod
def list_available_ports():
"""
List available serial ports (deprecated - use list_devices instead)
Returns:
list: Available port names
"""
return BBD203Driver.list_available_ports()
@staticmethod
def list_devices():
"""
List all connected ThorLabs BBD203 devices
Returns:
list: List of dicts with device info including 'serial' and 'port'
"""
return BBD203Driver.list_thorlabs_devices()
# ==================== Settings Management ====================
def apply_startup_settings(self) -> bool:
"""
Apply saved settings to the stage on startup
This includes:
- Velocity parameters for all axes
- Acceleration parameters for all axes
- Trigger configuration for all axes
Returns:
bool: True if all settings applied successfully
"""
print("INFO: Applying startup settings to stage")
success = True
# Apply velocity and acceleration settings
velocities = self.settings.get_all_velocities()
accelerations = self.settings.get_all_accelerations()
print(f" Velocity settings: X={velocities['x_axis']} mm/s, "
f"Y={velocities['y_axis']} mm/s, Z={velocities['z_axis']} mm/s")
print(f" Acceleration settings: X={accelerations['x_axis']} mm/s², "
f"Y={accelerations['y_axis']} mm/s², Z={accelerations['z_axis']} mm/s²")
# Set velocity/acceleration for each axis
if not self._driver.set_velocity_params(
self.X_AXIS, velocities['x_axis'], accelerations['x_axis']
):
success = False
time.sleep(0.05)
if not self._driver.set_velocity_params(
self.Y_AXIS, velocities['y_axis'], accelerations['y_axis']
):
success = False
time.sleep(0.05)
if not self._driver.set_velocity_params(
self.Z_AXIS, velocities['z_axis'], accelerations['z_axis']
):
success = False
time.sleep(0.05)
# Apply trigger configuration for each axis
for axis_name, channel in [('x_axis', self.X_AXIS),
('y_axis', self.Y_AXIS),
('z_axis', self.Z_AXIS)]:
trigger_config = self.settings.get_trigger_config(axis_name)
if not self._driver.set_trigger_mode(
channel,
trigger_config['mode'],
trigger_config['polarity'],
trigger_config['start_pos_fwd'],
trigger_config['start_pos_rev'],
trigger_config['interval_fwd'],
trigger_config['interval_rev']
):
success = False
time.sleep(0.05)
if success:
print("INFO: All startup settings applied successfully")
else:
print("WARNING: Some startup settings failed to apply")
return success
def save_current_settings(self) -> bool:
"""
Save current settings to file
Returns:
bool: True if saved successfully
"""
return self.settings.save()
def reload_settings(self) -> bool:
"""
Reload settings from file
Returns:
bool: True if reloaded successfully
"""
return self.settings.load()
def configure_velocity(self, x: Optional[float] = None,
y: Optional[float] = None,
z: Optional[float] = None,
save: bool = True) -> bool:
"""
Configure velocity for one or more axes
Args:
x: X-axis velocity in mm/s (None to keep current)
y: Y-axis velocity in mm/s (None to keep current)
z: Z-axis velocity in mm/s (None to keep current)
save: Save settings to file after updating
Returns:
bool: True if configuration successful
"""
success = True
if x is not None:
self.settings.set_velocity('x_axis', x)
accel = self.settings.get_acceleration('x_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.X_AXIS, x, accel)
time.sleep(0.05)
if y is not None:
self.settings.set_velocity('y_axis', y)
accel = self.settings.get_acceleration('y_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.Y_AXIS, y, accel)
time.sleep(0.05)
if z is not None:
self.settings.set_velocity('z_axis', z)
accel = self.settings.get_acceleration('z_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.Z_AXIS, z, accel)
time.sleep(0.05)
if save:
self.settings.save()
return success
def configure_acceleration(self, x: Optional[float] = None,
y: Optional[float] = None,
z: Optional[float] = None,
save: bool = True) -> bool:
"""
Configure acceleration for one or more axes
Args:
x: X-axis acceleration in mm/s² (None to keep current)
y: Y-axis acceleration in mm/s² (None to keep current)
z: Z-axis acceleration in mm/s² (None to keep current)
save: Save settings to file after updating
Returns:
bool: True if configuration successful
"""
success = True
if x is not None:
self.settings.set_acceleration('x_axis', x)
vel = self.settings.get_velocity('x_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.X_AXIS, vel, x)
time.sleep(0.05)
if y is not None:
self.settings.set_acceleration('y_axis', y)
vel = self.settings.get_velocity('y_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.Y_AXIS, vel, y)
time.sleep(0.05)
if z is not None:
self.settings.set_acceleration('z_axis', z)
vel = self.settings.get_velocity('z_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.Z_AXIS, vel, z)
time.sleep(0.05)
if save:
self.settings.save()
return success
def configure_trigger(self, axis: str, mode: int,
polarity: int = 0x01,
start_pos_fwd: float = 0.0,
start_pos_rev: float = 0.0,
interval_fwd: float = 0.0,
interval_rev: float = 0.0,
save: bool = True) -> bool:
"""
Configure trigger for specific axis
Args:
axis: Axis name ('X', 'Y', or 'Z')
mode: Trigger mode (TriggerMode enum value)
polarity: Trigger polarity (0x01 = active high, 0x02 = active low)
start_pos_fwd: Start position for forward trigger (mm)
start_pos_rev: Start position for reverse trigger (mm)
interval_fwd: Interval for forward trigger (mm)
interval_rev: Interval for reverse trigger (mm)
save: Save settings to file after updating
Returns:
bool: True if configuration successful
"""
axis = axis.upper()
if axis not in ['X', 'Y', 'Z']:
print(f"ERROR: Invalid axis: {axis}")
return False
axis_name = f"{axis.lower()}_axis"
channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis]
# Update settings
self.settings.set_trigger_config(
axis_name, mode, polarity,
start_pos_fwd, start_pos_rev,
interval_fwd, interval_rev
)
# Apply to hardware if connected
success = True
if self.is_connected():
success = self._driver.set_trigger_mode(
channel, mode, polarity,
start_pos_fwd, start_pos_rev,
interval_fwd, interval_rev
)
if save:
self.settings.save()
return success
def get_detailed_status(self) -> Dict:
"""
Get detailed status of all channels
Returns:
dict: Detailed status information
"""
return {
'connected': self.is_connected(),
'scanning': self._scanning,
'x_axis': self._driver.get_channel_status(self.X_AXIS),
'y_axis': self._driver.get_channel_status(self.Y_AXIS),
'z_axis': self._driver.get_channel_status(self.Z_AXIS),
'position': self.get_position(),
'settings': self.settings.get_all_settings()
}
-411
View File
@@ -1,411 +0,0 @@
"""
nueScan - Main Window Controller
Handles all UI interactions and coordinates hardware communication
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import os
from PyQt6 import uic
from PyQt6.QtWidgets import QMainWindow, QMessageBox
from PyQt6.QtCore import QTimer
# Import dialog controllers
from dialogs.genesis_dialog import GenesisDialog
from dialogs.helios_dialog import HeliosDialog
from dialogs.scan_active_dialog import ScanActiveDialog
from dialogs.status_dialog import StatusDialog
from dialogs.oscope_dialog import OscopeDialog
# Import hardware controllers
from hardware.thorlabs_stage import ThorLabsStage
from hardware.t3r_device import T3RDevice
from hardware.microscope import MicroscopeController
class NueScanMainWindow(QMainWindow):
"""Main window for nueScan application"""
def __init__(self):
super().__init__()
# Load UI file
ui_path = os.path.join(os.path.dirname(__file__), 'nuescan_mainwindow.ui')
uic.loadUi(ui_path, self)
# Set window title
self.setWindowTitle("nueScan - SRAS Scan Planning and Control")
# Initialize hardware controllers
self.thorlabs_stage = ThorLabsStage()
self.t3r_device = T3RDevice()
self.microscope = MicroscopeController()
# Initialize dialogs (create on demand)
self.genesis_dialog = None
self.helios_dialog = None
self.scan_active_dialog = None
self.status_dialog = StatusDialog(self, self.thorlabs_stage, self.t3r_device, self.microscope)
self.oscope_dialog = OscopeDialog(self, self.microscope)
# Status update timer
self.status_timer = QTimer()
self.status_timer.timeout.connect(self._update_all_status)
self.status_timer.start(100) # Update every 100ms
# Connect all UI signals
self._connect_signals()
# Initialize UI state
self._initialize_ui()
def _connect_signals(self):
"""Connect all UI signals to handler methods"""
# ===== Button Click Handlers =====
self.btn_toggle_mls.clicked.connect(self.on_toggle_mls_clicked)
self.btn_refresh_com.clicked.connect(self.on_refresh_com_clicked)
self.button_connect_com.clicked.connect(self.on_connect_com_clicked)
self.btn_begin_scanning.clicked.connect(self.on_begin_scanning_clicked)
self.btn_toggle_status_window.clicked.connect(self.on_toggle_status_window_clicked)
self.actionShow_Oscope_Settings.triggered.connect(self.on_show_oscope_settings_clicked)
self.actionDigital_IO_State.triggered.connect(self.on_show_digital_io_state_clicked)
self.actionMLS203_Information.triggered.connect(self.on_show_mls203_information_clicked)
self.actionTransfer_System_Editor.triggered.connect(self.on_show_transfer_system_editor_clicked)
self.actionHelios_Settings.triggered.connect(self.on_show_helios_settings_clicked)
self.actionGenesis_Settings.triggered.connect(self.on_show_genesis_settings_clicked)
# ===== LineEdit Text Changed Handlers =====
self.le_stage_serial.textChanged.connect(self.on_stage_serial_changed)
self.le_x_start_coord.textChanged.connect(self.on_x_start_coord_changed)
self.le_x_delta.textChanged.connect(self.on_x_delta_changed)
self.le_y_start_coord.textChanged.connect(self.on_y_start_coord_changed)
self.le_y_delta.textChanged.connect(self.on_y_delta_changed)
self.le_file_suffix.textChanged.connect(self.on_file_suffix_changed)
# ===== ComboBox Value Changed Handlers =====
self.cb_num_scans.currentIndexChanged.connect(self.on_num_scans_changed)
self.cb_row_spacing.currentIndexChanged.connect(self.on_row_spacing_changed)
def _initialize_ui(self):
"""Initialize UI with default values"""
# Set placeholder text for stage serial
self.le_stage_serial.setPlaceholderText("Enter BBD203 serial (e.g., 83123456)")
# Refresh COM ports
self.on_refresh_com_clicked()
# ==================== Button Click Handlers ====================
def on_toggle_status_window_clicked(self):
"""Toggle the visibility of the status window"""
if self.status_dialog.isVisible():
self.status_dialog.hide()
else:
self.status_dialog.show()
def on_show_oscope_settings_clicked(self):
"""Toggle the visibility of the oscope settings window"""
if self.oscope_dialog.isVisible():
self.oscope_dialog.hide()
else:
self.oscope_dialog.show()
def on_show_digital_io_state_clicked(self):
"""Show the digital IO state dialog"""
print("DEBUG: Show digital IO state clicked")
def on_show_mls203_information_clicked(self):
"""Show the MLS203 information dialog"""
print("DEBUG: Show MLS203 information clicked")
def on_show_transfer_system_editor_clicked(self):
"""Show the transfer system editor dialog"""
print("DEBUG: Show transfer system editor clicked")
def on_toggle_mls_clicked(self):
"""Handle ThorLabs MLS stage connect/disconnect"""
print("DEBUG: MLS toggle button clicked")
if self.thorlabs_stage.is_connected():
self.thorlabs_stage.disconnect()
self.btn_toggle_mls.setText("Connect")
else:
serial_number = self.le_stage_serial.text().strip()
if not serial_number:
QMessageBox.warning(
self, "No Serial Number",
"Please enter the BBD203 serial number.\n\n"
"The serial number is printed on the controller label\n"
"(e.g., '83123456')."
)
return
print(f"INFO: Attempting to connect to BBD203 serial: {serial_number}")
success = self.thorlabs_stage.connect(serial_number)
if success:
self.btn_toggle_mls.setText("Disconnect")
QMessageBox.information(
self, "Connected",
f"Successfully connected to BBD203 controller\n"
f"Serial: {serial_number}\n\n"
f"All channels enabled. Ready to home axes."
)
else:
# Show available devices
devices = self.thorlabs_stage.list_devices()
if devices:
device_list = "\n".join([
f" Serial: {d['serial']} ({d['description']})"
for d in devices
])
msg = (f"Failed to connect to BBD203 with serial: {serial_number}\n\n"
f"Available ThorLabs devices:\n{device_list}")
else:
msg = (f"Failed to connect to BBD203 with serial: {serial_number}\n\n"
f"No ThorLabs devices found.\n"
f"Check USB connection and driver installation.")
QMessageBox.warning(self, "Connection Error", msg)
def on_refresh_com_clicked(self):
"""Refresh available COM ports"""
print("DEBUG: Refresh COM ports clicked")
self.combo_com_ports.clear()
ports = self.t3r_device.get_available_ports()
self.combo_com_ports.addItems(ports)
def on_connect_com_clicked(self):
"""Connect to selected COM port"""
print("DEBUG: Connect COM button clicked")
port = self.combo_com_ports.currentText()
if port:
success = self.t3r_device.connect(port)
if success:
self.button_connect_com.setText("Disconnect")
else:
QMessageBox.warning(self, "Connection Error", f"Failed to connect to {port}")
else:
QMessageBox.warning(self, "No Port Selected", "Please select a COM port")
def on_show_helios_settings_clicked(self):
"""Show Helios settings dialog"""
print("DEBUG: Show Helios settings clicked")
if not self.helios_dialog:
self.helios_dialog = HeliosDialog(self)
if self.helios_dialog.exec():
# User clicked OK, apply settings
settings = self.helios_dialog.get_settings()
self.microscope.apply_helios_settings(settings)
print(f"DEBUG: Applied Helios settings: {settings}")
def on_show_genesis_settings_clicked(self):
"""Show Genesis settings dialog"""
print("DEBUG: Show Genesis settings clicked")
if not self.genesis_dialog:
self.genesis_dialog = GenesisDialog(self)
if self.genesis_dialog.exec():
# User clicked OK, apply settings
settings = self.genesis_dialog.get_settings()
self.microscope.apply_genesis_settings(settings)
print(f"DEBUG: Applied Genesis settings: {settings}")
def on_begin_scanning_clicked(self):
"""Start the scanning process"""
print("DEBUG: Begin scanning clicked")
# Validate that all systems are ready
if not self._validate_scan_ready():
return
# Create and show scan active dialog
if not self.scan_active_dialog:
self.scan_active_dialog = ScanActiveDialog(self)
# Start the scan
self._start_scan()
# Show progress dialog
self.scan_active_dialog.exec()
# ==================== ComboBox Change Handlers ====================
def on_num_scans_changed(self, index):
"""Handle number of scans change"""
num_scans = self.cb_num_scans.currentText()
print(f"DEBUG: Number of scans changed to: {num_scans}")
self._recalculate_scan_parameters()
def on_row_spacing_changed(self, index):
"""Handle row spacing change"""
spacing = self.cb_row_spacing.currentText()
print(f"DEBUG: Row spacing changed to: {spacing}")
self._recalculate_scan_parameters()
# ==================== LineEdit Text Changed Handlers ====================
def on_stage_serial_changed(self, text):
"""Handle stage serial number change"""
print(f"DEBUG: Stage serial changed to: {text}")
def on_x_start_coord_changed(self, text):
"""Handle X start coordinate change"""
print(f"DEBUG: X start coordinate changed to: {text}")
self._recalculate_scan_parameters()
def on_x_delta_changed(self, text):
"""Handle X delta change"""
print(f"DEBUG: X delta changed to: {text}")
self._recalculate_scan_parameters()
def on_y_start_coord_changed(self, text):
"""Handle Y start coordinate change"""
print(f"DEBUG: Y start coordinate changed to: {text}")
self._recalculate_scan_parameters()
def on_y_delta_changed(self, text):
"""Handle Y delta change"""
print(f"DEBUG: Y delta changed to: {text}")
self._recalculate_scan_parameters()
def on_file_suffix_changed(self, text):
"""Handle file suffix change"""
print(f"DEBUG: File suffix changed to: {text}")
# ==================== Status Update Methods ====================
def _update_all_status(self):
"""Update all status labels with current hardware states"""
self.status_dialog.update_all_status()
# ==================== Scan Management Methods ====================
def _validate_scan_ready(self):
"""Validate that all systems are ready for scanning"""
if not self.thorlabs_stage.is_connected():
QMessageBox.warning(
self, "Not Ready", "ThorLabs stage is not connected")
return False
if not self.t3r_device.is_connected():
QMessageBox.warning(self, "Not Ready", "T3R device is not connected")
return False
# Add more validation as needed
return True
def _start_scan(self):
"""Initialize and start the scanning process"""
print("DEBUG: Starting scan process")
# Collect scan parameters
params = self._collect_scan_parameters()
# Initialize hardware for scanning
self.thorlabs_stage.prepare_for_scan(params)
self.t3r_device.prepare_for_scan(params)
self.microscope.prepare_for_scan(params)
# Start the scan (would be implemented in actual hardware controllers)
print(f"DEBUG: Scan parameters: {params}")
def _collect_scan_parameters(self):
"""Collect all scan parameters from UI"""
try:
params = {
'x_start': float(self.le_x_start_coord.text() or 0),
'x_delta': float(self.le_x_delta.text() or 0),
'y_start': float(self.le_y_start_coord.text() or 0),
'y_delta': float(self.le_y_delta.text() or 0),
'num_scans': int(self.cb_num_scans.currentText() or 1),
'row_spacing': float(self.cb_row_spacing.currentText() or 0.1),
'file_suffix': self.le_file_suffix.text(),
'pd_trig_voltage': float(self.oscope_dialog.le_set_pd_trig_voltage.text() or 0),
'sample_min_bias_voltage': float(self.oscope_dialog.le_set_sample_thresh_voltage.text() or 0),
'trigger_voltage': float(self.oscope_dialog.le_set_trigger_voltage.text() or 0),
'visa_address': self.oscope_dialog.le_oscope_visa_address.text(),
'phototrigger_channel': self.oscope_dialog.cb_set_trig_channel.currentText(),
'bias_a_channel': self.oscope_dialog.cb_set_bias_a_ch.currentText(),
'bias_b_channel': self.oscope_dialog.cb_set_bias_b_ch.currentText(),
'rf_saw_channel': self.oscope_dialog.cb_set_saw_channel.currentText()
}
except ValueError:
params = {
'x_start': 0, 'x_delta': 0, 'y_start': 0, 'y_delta': 0,
'num_scans': 1, 'row_spacing': 0.1, 'file_suffix': '',
'pd_trig_voltage': 0, 'sample_min_bias_voltage': 0, 'trigger_voltage': 0,
'visa_address': '', 'phototrigger_channel': 'CH1', 'bias_a_channel': 'CH1',
'bias_b_channel': 'CH1', 'rf_saw_channel': 'CH1'
}
return params
def _recalculate_scan_parameters(self):
"""Recalculate and update scan statistics"""
params = self._collect_scan_parameters()
# Calculate points per row (stub calculation)
if params['x_delta'] > 0:
points_per_row = int(abs(params['x_start']) / params['x_delta'])
else:
points_per_row = 0
# Calculate rows per scan (stub calculation)
if params['row_spacing'] > 0 and params['y_delta'] > 0:
rows_per_scan = int(abs(params['y_delta']) / params['row_spacing'])
else:
rows_per_scan = 0
# Calculate totals
scans_in_set = params['num_scans']
total_records = points_per_row * rows_per_scan * scans_in_set
total_points = total_records
# Estimate file size (1KB per point as example)
estimated_size_gb = (total_points * 1024) / (1024 ** 3)
# Update labels
self.l_scan_ppr.setText(str(points_per_row))
self.l_scan_rps.setText(str(rows_per_scan))
self.l_scan_sis.setText(str(scans_in_set))
self.l_scan_total_records.setText(str(total_records))
self.l_scan_total_points.setText(str(total_points))
self.l_scan_estimated_size.setText(f"{estimated_size_gb:.2f}GB")
# Calculate angle spacing
if scans_in_set > 1:
angle_spacing = 360.0 / scans_in_set
else:
angle_spacing = 0
self.l_scan_angle_spacing.setText(f"{angle_spacing:.2f}°")
# ==================== Progress Update Methods ====================
def update_total_progress(self, current, total):
"""
Update total scan progress
Called from scan control logic to update progress bar
"""
if self.scan_active_dialog:
self.scan_active_dialog.update_total_progress(current, total)
def update_current_scan_progress(self, current, total):
"""
Update current scan progress
Called from scan control logic to update progress bar
"""
if self.scan_active_dialog:
self.scan_active_dialog.update_current_scan_progress(current, total)
def update_scan_status(self, scan_num, total_scans, row_num, total_rows, time_remaining):
"""
Update scan status information
Called from scan control logic
"""
if self.scan_active_dialog:
self.scan_active_dialog.update_status(
scan_num, total_scans, row_num, total_rows, time_remaining
)
-78
View File
@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>168</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Scanning Power [mW]:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="le_genesis_power_mw"/>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>
-107
View File
@@ -1,107 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>173</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="2" column="1">
<widget class="QLineEdit" name="le_helios_current"/>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="le_helios_frequency"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Helios Frequency [Hz]:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Laser Current [mA]:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Helios COM Port:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="cb_helios_port"/>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>
-484
View File
@@ -1,484 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>nueScanWindow</class>
<widget class="QMainWindow" name="nueScanWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>830</width>
<height>681</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QGridLayout" name="topGridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>ThorLABS MLS Stage Serial:</string>
</property>
</widget>
</item>
<item row="0" column="1" colspan="2">
<widget class="QLineEdit" name="le_stage_serial"/>
</item>
<item row="0" column="3" colspan="2">
<widget class="QPushButton" name="btn_toggle_mls">
<property name="text">
<string>Connect</string>
</property>
</widget>
</item>
<item row="0" column="5" colspan="2">
<widget class="QPushButton" name="btn_toggle_status_window">
<property name="text">
<string>Status</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>T3R COM Port:</string>
</property>
</widget>
</item>
<item row="1" column="1" colspan="2">
<widget class="QComboBox" name="combo_com_ports"/>
</item>
<item row="1" column="3" colspan="2">
<widget class="QPushButton" name="btn_refresh_com">
<property name="text">
<string>Refresh Serial Devices</string>
</property>
</widget>
</item>
<item row="1" column="5" colspan="2">
<widget class="QPushButton" name="button_connect_com">
<property name="text">
<string>Connect</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="7">
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QLabel" name="label_44">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Scan Details and Settings:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing</set>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_50">
<property name="text">
<string>Coordinate and Spacing Settings:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QLabel" name="label_45">
<property name="text">
<string>X-Begin [mm]</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="QLineEdit" name="le_x_start_coord"/>
</item>
<item row="5" column="2">
<widget class="QLabel" name="label_46">
<property name="text">
<string>X-Delta [mm]</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="6" column="2">
<widget class="QLineEdit" name="le_x_delta"/>
</item>
<item row="5" column="3">
<widget class="QLabel" name="label_47">
<property name="text">
<string>Y-Begin [mm]</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="6" column="3">
<widget class="QLineEdit" name="le_y_start_coord"/>
</item>
<item row="5" column="4">
<widget class="QLabel" name="label_48">
<property name="text">
<string>Y-Delta [mm]</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="6" column="4">
<widget class="QLineEdit" name="le_y_delta"/>
</item>
<item row="5" column="5">
<widget class="QLabel" name="label_49">
<property name="text">
<string>Row
Spacing [mm]:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="6" column="5">
<widget class="QComboBox" name="cb_row_spacing"/>
</item>
<item row="7" column="0">
<widget class="QLabel" name="label_52">
<property name="text">
<string>Angular Settings:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="8" column="1">
<widget class="QLabel" name="label_53">
<property name="text">
<string># of Scans:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="9" column="1">
<widget class="QComboBox" name="cb_num_scans"/>
</item>
<item row="8" column="2">
<widget class="QLabel" name="label_54">
<property name="text">
<string>Equivalent
Angular Spacing</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="9" column="2">
<widget class="QLabel" name="l_scan_angle_spacing">
<property name="text">
<string>0</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="10" column="0">
<widget class="QLabel" name="label_58">
<property name="text">
<string>File Suffix:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing</set>
</property>
</widget>
</item>
<item row="11" column="1" colspan="3">
<widget class="QLineEdit" name="le_file_suffix"/>
</item>
<item row="12" column="0" colspan="7">
<widget class="Line" name="line_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="13" column="0" colspan="2">
<widget class="QLabel" name="label_100">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Timing and Size Information:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing</set>
</property>
</widget>
</item>
<item row="14" column="1">
<widget class="QLabel" name="label_173">
<property name="text">
<string>Points Per Row</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="15" column="1">
<widget class="QLabel" name="l_scan_ppr">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="14" column="2">
<widget class="QLabel" name="label_174">
<property name="text">
<string>Rows Per Scan</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="15" column="2">
<widget class="QLabel" name="l_scan_rps">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="14" column="3">
<widget class="QLabel" name="label_175">
<property name="text">
<string>Scans In Set</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="15" column="3">
<widget class="QLabel" name="l_scan_sis">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="14" column="4">
<widget class="QLabel" name="label_176">
<property name="text">
<string>Total Records</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="15" column="4">
<widget class="QLabel" name="l_scan_total_records">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="14" column="5">
<widget class="QLabel" name="label_177">
<property name="text">
<string>Total Points
Captured</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="15" column="5">
<widget class="QLabel" name="l_scan_total_points">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>0</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="14" column="6">
<widget class="QLabel" name="label_178">
<property name="text">
<string>Current Size
On Disk</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="15" column="6">
<widget class="QLabel" name="l_scan_estimated_size">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>0GB</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="16" column="0" colspan="7">
<widget class="Line" name="line_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="17" column="4" colspan="3">
<widget class="QPushButton" name="btn_begin_scanning">
<property name="font">
<font>
<pointsize>20</pointsize>
</font>
</property>
<property name="text">
<string>Begin Scan</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="neuScanMBar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>830</width>
<height>30</height>
</rect>
</property>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>File</string>
</property>
<addaction name="actionShow_Oscope_Settings"/>
</widget>
<widget class="QMenu" name="menuView">
<property name="title">
<string>View</string>
</property>
<addaction name="actionDigital_IO_State"/>
<addaction name="separator"/>
<addaction name="actionMLS203_Information"/>
<addaction name="actionTransfer_System_Editor"/>
<addaction name="separator"/>
<addaction name="actionHelios_Settings"/>
<addaction name="actionGenesis_Settings"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuView"/>
</widget>
<widget class="QStatusBar" name="neuScanSBar"/>
<action name="actionShow_Oscope_Settings">
<property name="text">
<string>Show Oscope Settings</string>
</property>
</action>
<action name="actionDigital_IO_State">
<property name="text">
<string>Digital IO State</string>
</property>
</action>
<action name="actionMLS203_Information">
<property name="text">
<string>MLS203 Information</string>
</property>
</action>
<action name="actionTransfer_System_Editor">
<property name="text">
<string>Transfer System Editor</string>
</property>
</action>
<action name="actionHelios_Settings">
<property name="text">
<string>Helios Settings</string>
</property>
</action>
<action name="actionGenesis_Settings">
<property name="text">
<string>Genesis Settings</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>
-170
View File
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>OscopeDialog</class>
<widget class="QDialog" name="OscopeDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>466</width>
<height>358</height>
</rect>
</property>
<property name="windowTitle">
<string>Oscilloscope Settings</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QGridLayout" name="topGridLayout">
<item row="0" column="0" colspan="3">
<widget class="QLabel" name="label_57">
<property name="font">
<font>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>Oscilloscope Settings</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing</set>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_60">
<property name="text">
<string>Phototrigger
Channel</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QComboBox" name="cb_set_trig_channel"/>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_59">
<property name="text">
<string>Bias A
Channel</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="cb_set_bias_a_ch"/>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_61">
<property name="text">
<string>Bias B
Channel</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QComboBox" name="cb_set_bias_b_ch"/>
</item>
<item row="1" column="3">
<widget class="QLabel" name="label_96">
<property name="text">
<string>RF/SAW
Channel</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QComboBox" name="cb_set_saw_channel"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_97">
<property name="text">
<string>PD Trigger
Voltage [V]:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter</set>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLineEdit" name="le_set_pd_trig_voltage"/>
</item>
<item row="3" column="1">
<widget class="QLabel" name="label_trigger_voltage">
<property name="text">
<string>Trigger
Voltage [V]:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter</set>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLineEdit" name="le_set_trigger_voltage"/>
</item>
<item row="3" column="2">
<widget class="QLabel" name="label_99">
<property name="text">
<string>Sample
Min Bias [V]:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter</set>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QLineEdit" name="le_set_sample_thresh_voltage"/>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_98">
<property name="text">
<string>VISA Address:</string>
</property>
</widget>
</item>
<item row="5" column="1" colspan="3">
<widget class="QLineEdit" name="le_oscope_visa_address"/>
</item>
<item row="5" column="4">
<widget class="QPushButton" name="btn_test_scope_connection">
<property name="text">
<string>Test
Connect</string>
</property>
</widget>
</item>
<item row="6" column="3">
<widget class="QPushButton" name="btn_save_scope_settings">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
<item row="6" column="4">
<widget class="QPushButton" name="btn_cancel_scope_settings">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
-275
View File
@@ -1,275 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>858</width>
<height>298</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="8">
<widget class="QLabel" name="label">
<property name="font">
<font>
<pointsize>24</pointsize>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>SCANNING...</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="10" column="1" colspan="7">
<widget class="QPushButton" name="pb_cancel_scan">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Cancel Scan</string>
</property>
</widget>
</item>
<item row="4" column="4">
<widget class="QLabel" name="label_4">
<property name="font">
<font>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>of</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="3" column="0" colspan="8">
<widget class="QProgressBar" name="pbar_total_scan">
<property name="value">
<number>24</number>
</property>
</widget>
</item>
<item row="7" column="1">
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="7" column="6" colspan="2">
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="2">
<widget class="QLabel" name="label_2">
<property name="font">
<font>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>Scan</string>
</property>
</widget>
</item>
<item row="4" column="5">
<widget class="QLabel" name="l_status_total_scans">
<property name="font">
<font>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>09</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="7" column="3">
<widget class="QLabel" name="l_status_current_row">
<property name="font">
<font>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>000</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="7" column="5">
<widget class="QLabel" name="l_status_total_rows">
<property name="font">
<font>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>000</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="6" column="1" colspan="7">
<widget class="QProgressBar" name="pbar_this_scan">
<property name="value">
<number>24</number>
</property>
</widget>
</item>
<item row="4" column="3">
<widget class="QLabel" name="l_status_current_scan">
<property name="font">
<font>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>01</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="7" column="4">
<widget class="QLabel" name="label_8">
<property name="font">
<font>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>of</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="7" column="2">
<widget class="QLabel" name="label_7">
<property name="font">
<font>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string>Row</string>
</property>
</widget>
</item>
<item row="5" column="3">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="1" column="0" colspan="8">
<widget class="QLabel" name="l_est_time_done">
<property name="font">
<font>
<pointsize>16</pointsize>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>00:00:00 remaining....</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="4" column="1">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="7">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="8" column="3">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
-473
View File
@@ -1,473 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>StatusDialog</class>
<widget class="QDialog" name="StatusDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>835</width>
<height>260</height>
</rect>
</property>
<property name="windowTitle">
<string>Status Indicators</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QGridLayout" name="statusGridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Stage Status Information:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_14">
<property name="text">
<string>isConnected?</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLabel" name="l_is_mls_connected">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLabel" name="label_9">
<property name="text">
<string>isXHomed?</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QLabel" name="l_is_mls_x_home">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QLabel" name="label_11">
<property name="text">
<string>isYHomed?</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QLabel" name="l_is_mls_y_home">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="4">
<widget class="QLabel" name="label_12">
<property name="text">
<string>isReady?</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="4">
<widget class="QLabel" name="l_is_mls_ready">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="5">
<widget class="QLabel" name="label_13">
<property name="text">
<string>isScanning?</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="2" column="5">
<widget class="QLabel" name="l_is_mls_scanning">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_16">
<property name="text">
<string>T3R-SL Status Information:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing</set>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLabel" name="label_17">
<property name="text">
<string>isConnected?</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QLabel" name="l_is_t3r_connected">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QLabel" name="label_19">
<property name="text">
<string>isHomed?</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="QLabel" name="l_is_t3r_homed">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="4" column="3">
<widget class="QLabel" name="label_20">
<property name="text">
<string>isReady?</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignHCenter</set>
</property>
</widget>
</item>
<item row="5" column="3">
<widget class="QLabel" name="l_is_t3r_ready">
<property name="font">
<font>
<family>Sans Serif</family>
<pointsize>14</pointsize>
<italic>false</italic>
<bold>false</bold>
</font>
</property>
<property name="styleSheet">
<string notr="true">font: 14pt &quot;Sans Serif&quot;;</string>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="label_100">
<property name="text">
<string>Microscope Status Information:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing</set>
</property>
</widget>
</item>
<item row="7" column="2">
<widget class="QLabel" name="l_is_helios_ready">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="7" column="3">
<widget class="QLabel" name="l_is_helios_interlocked">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="7" column="4">
<widget class="QLabel" name="l_is_genesis_ready">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="7" column="5">
<widget class="QLabel" name="l_is_genesis_interlocked">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Yes</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="8" column="0">
<widget class="QLabel" name="label_157">
<property name="text">
<string>Transfer System Information:</string>
</property>
<property name="alignment">
<set>Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing</set>
</property>
</widget>
</item>
<item row="9" column="1">
<widget class="QLabel" name="label_158">
<property name="text">
<string>Outputs To
Robo-met.3D</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="10" column="2">
<widget class="QLabel" name="l_sras_ok">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Low (0)</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="10" column="3">
<widget class="QLabel" name="l_sras_ctl">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Low (0)</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="10" column="4">
<widget class="QLabel" name="l_sras_done">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Low (0)</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="10" column="5">
<widget class="QLabel" name="l_sras_error">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Low (0)</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="11" column="1">
<widget class="QLabel" name="label_159">
<property name="text">
<string>Inputs from
Robo-met.3D</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="12" column="2">
<widget class="QLabel" name="l_r3d_estop_ok">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Low (0)</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="12" column="3">
<widget class="QLabel" name="l_r3d_rtl">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Low (0)</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="12" column="4">
<widget class="QLabel" name="l_r3d_rts">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Low (0)</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item row="12" column="5">
<widget class="QLabel" name="l_r3d_spare">
<property name="font">
<font>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Low (0)</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
-19
View File
@@ -1,19 +0,0 @@
# nueScan - SRAS Scan Planning and Control Software
# Python Dependencies
# GUI Framework
PyQt6>=6.4.0
# Serial Communication (for hardware interfaces)
pyserial>=3.5
# VISA instrument control (for oscilloscope)
pyvisa>=1.13.0
pyvisa-py>=0.7.0
# Optional: For enhanced serial port detection
# pyserial-asyncio>=0.6
# Development dependencies (optional)
# pytest>=7.0.0
# pytest-qt>=4.0.0
-797
View File
@@ -1,797 +0,0 @@
#!/usr/bin/env python3
"""
ThorLabs Stage Test Application
Qt6-based GUI for testing and verifying the BBD203/MLS stage driver functionality
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import sys
import time
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGroupBox, QPushButton, QLabel, QLineEdit, QComboBox, QTextEdit,
QSpinBox, QDoubleSpinBox, QCheckBox, QGridLayout, QMessageBox, QTabWidget
)
from PyQt6.QtCore import QTimer, Qt
from PyQt6.QtGui import QFont
from hardware.thorlabs_stage import ThorLabsStage
from hardware.bbd203_protocol import TriggerMode
class StageTestWindow(QMainWindow):
"""Main window for stage testing application"""
def __init__(self):
super().__init__()
self.stage = ThorLabsStage()
self.status_timer = QTimer()
self.status_timer.timeout.connect(self.update_status)
self.init_ui()
self.refresh_devices()
def init_ui(self):
"""Initialize the user interface"""
self.setWindowTitle("ThorLabs Stage Test Application")
self.setGeometry(100, 100, 900, 700)
# Central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Main layout
main_layout = QVBoxLayout(central_widget)
# Title
title_label = QLabel("ThorLabs BBD203/MLS Stage Driver Test")
title_font = QFont()
title_font.setPointSize(16)
title_font.setBold(True)
title_label.setFont(title_font)
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_layout.addWidget(title_label)
# Connection section
main_layout.addWidget(self.create_connection_group())
# Tabbed interface for different sections
tab_widget = QTabWidget()
# Control tab
control_widget = QWidget()
control_layout = QVBoxLayout(control_widget)
controls_layout = QHBoxLayout()
controls_layout.addWidget(self.create_homing_group())
controls_layout.addWidget(self.create_motion_group())
control_layout.addLayout(controls_layout)
control_layout.addWidget(self.create_status_group())
tab_widget.addTab(control_widget, "Control")
# Settings tab
settings_widget = QWidget()
settings_layout = QVBoxLayout(settings_widget)
settings_layout.addWidget(self.create_settings_group())
tab_widget.addTab(settings_widget, "Settings")
main_layout.addWidget(tab_widget)
# Log section
main_layout.addWidget(self.create_log_group())
# Start status updates
self.status_timer.start(200) # Update every 200ms
def create_connection_group(self) -> QGroupBox:
"""Create connection control group"""
group = QGroupBox("Connection")
layout = QGridLayout()
# Device selection
layout.addWidget(QLabel("Device:"), 0, 0)
self.device_combo = QComboBox()
layout.addWidget(self.device_combo, 0, 1, 1, 2)
self.refresh_btn = QPushButton("Refresh Devices")
self.refresh_btn.clicked.connect(self.refresh_devices)
layout.addWidget(self.refresh_btn, 0, 3)
# Serial number entry
layout.addWidget(QLabel("Serial Number:"), 1, 0)
self.serial_edit = QLineEdit()
self.serial_edit.setPlaceholderText("e.g., 83123456")
layout.addWidget(self.serial_edit, 1, 1, 1, 2)
# Baudrate
layout.addWidget(QLabel("Baudrate:"), 2, 0)
self.baudrate_combo = QComboBox()
self.baudrate_combo.addItems(["115200", "9600", "19200", "38400", "57600"])
self.baudrate_combo.setCurrentText("115200")
layout.addWidget(self.baudrate_combo, 2, 1)
# Connect/Disconnect buttons
self.connect_btn = QPushButton("Connect")
self.connect_btn.clicked.connect(self.connect_stage)
layout.addWidget(self.connect_btn, 2, 2)
self.disconnect_btn = QPushButton("Disconnect")
self.disconnect_btn.clicked.connect(self.disconnect_stage)
self.disconnect_btn.setEnabled(False)
layout.addWidget(self.disconnect_btn, 2, 3)
# Identify button
self.identify_btn = QPushButton("Identify (Flash LEDs)")
self.identify_btn.clicked.connect(self.identify_stage)
self.identify_btn.setEnabled(False)
layout.addWidget(self.identify_btn, 3, 0, 1, 4)
group.setLayout(layout)
return group
def create_homing_group(self) -> QGroupBox:
"""Create homing control group"""
group = QGroupBox("Homing")
layout = QVBoxLayout()
# Home all button
self.home_all_btn = QPushButton("Home All Axes")
self.home_all_btn.clicked.connect(self.home_all)
self.home_all_btn.setEnabled(False)
layout.addWidget(self.home_all_btn)
# Individual axis homing
axis_layout = QHBoxLayout()
self.home_x_btn = QPushButton("Home X")
self.home_x_btn.clicked.connect(lambda: self.home_axis('X'))
self.home_x_btn.setEnabled(False)
axis_layout.addWidget(self.home_x_btn)
self.home_y_btn = QPushButton("Home Y")
self.home_y_btn.clicked.connect(lambda: self.home_axis('Y'))
self.home_y_btn.setEnabled(False)
axis_layout.addWidget(self.home_y_btn)
self.home_z_btn = QPushButton("Home Z")
self.home_z_btn.clicked.connect(lambda: self.home_axis('Z'))
self.home_z_btn.setEnabled(False)
axis_layout.addWidget(self.home_z_btn)
layout.addLayout(axis_layout)
group.setLayout(layout)
return group
def create_motion_group(self) -> QGroupBox:
"""Create motion control group"""
group = QGroupBox("Motion Control")
layout = QGridLayout()
# Absolute move controls
layout.addWidget(QLabel("Absolute Move (mm):"), 0, 0, 1, 3)
layout.addWidget(QLabel("X:"), 1, 0)
self.abs_x_spin = QDoubleSpinBox()
self.abs_x_spin.setRange(-100, 100)
self.abs_x_spin.setDecimals(3)
self.abs_x_spin.setSingleStep(0.1)
layout.addWidget(self.abs_x_spin, 1, 1)
layout.addWidget(QLabel("Y:"), 2, 0)
self.abs_y_spin = QDoubleSpinBox()
self.abs_y_spin.setRange(-100, 100)
self.abs_y_spin.setDecimals(3)
self.abs_y_spin.setSingleStep(0.1)
layout.addWidget(self.abs_y_spin, 2, 1)
layout.addWidget(QLabel("Z:"), 3, 0)
self.abs_z_spin = QDoubleSpinBox()
self.abs_z_spin.setRange(-100, 100)
self.abs_z_spin.setDecimals(3)
self.abs_z_spin.setSingleStep(0.1)
layout.addWidget(self.abs_z_spin, 3, 1)
self.move_abs_btn = QPushButton("Move Absolute")
self.move_abs_btn.clicked.connect(self.move_absolute)
self.move_abs_btn.setEnabled(False)
layout.addWidget(self.move_abs_btn, 4, 0, 1, 2)
# Relative move controls
layout.addWidget(QLabel("Relative Move (mm):"), 5, 0, 1, 3)
layout.addWidget(QLabel("dX:"), 6, 0)
self.rel_x_spin = QDoubleSpinBox()
self.rel_x_spin.setRange(-10, 10)
self.rel_x_spin.setDecimals(3)
self.rel_x_spin.setSingleStep(0.1)
layout.addWidget(self.rel_x_spin, 6, 1)
layout.addWidget(QLabel("dY:"), 7, 0)
self.rel_y_spin = QDoubleSpinBox()
self.rel_y_spin.setRange(-10, 10)
self.rel_y_spin.setDecimals(3)
self.rel_y_spin.setSingleStep(0.1)
layout.addWidget(self.rel_y_spin, 7, 1)
layout.addWidget(QLabel("dZ:"), 8, 0)
self.rel_z_spin = QDoubleSpinBox()
self.rel_z_spin.setRange(-10, 10)
self.rel_z_spin.setDecimals(3)
self.rel_z_spin.setSingleStep(0.1)
layout.addWidget(self.rel_z_spin, 8, 1)
self.move_rel_btn = QPushButton("Move Relative")
self.move_rel_btn.clicked.connect(self.move_relative)
self.move_rel_btn.setEnabled(False)
layout.addWidget(self.move_rel_btn, 9, 0, 1, 2)
# Stop button
self.stop_btn = QPushButton("STOP ALL")
self.stop_btn.clicked.connect(self.stop_all)
self.stop_btn.setEnabled(False)
self.stop_btn.setStyleSheet("background-color: #ff4444; color: white; font-weight: bold;")
layout.addWidget(self.stop_btn, 10, 0, 1, 2)
group.setLayout(layout)
return group
def create_settings_group(self) -> QGroupBox:
"""Create settings configuration group"""
group = QGroupBox("Stage Settings")
layout = QVBoxLayout()
# Velocity settings
vel_group = QGroupBox("Velocity (mm/s)")
vel_layout = QGridLayout()
vel_layout.addWidget(QLabel("X Axis:"), 0, 0)
self.vel_x_spin = QDoubleSpinBox()
self.vel_x_spin.setRange(0.01, 10.0)
self.vel_x_spin.setDecimals(3)
self.vel_x_spin.setSingleStep(0.1)
self.vel_x_spin.setValue(1.0)
vel_layout.addWidget(self.vel_x_spin, 0, 1)
vel_layout.addWidget(QLabel("Y Axis:"), 1, 0)
self.vel_y_spin = QDoubleSpinBox()
self.vel_y_spin.setRange(0.01, 10.0)
self.vel_y_spin.setDecimals(3)
self.vel_y_spin.setSingleStep(0.1)
self.vel_y_spin.setValue(1.0)
vel_layout.addWidget(self.vel_y_spin, 1, 1)
vel_layout.addWidget(QLabel("Z Axis:"), 2, 0)
self.vel_z_spin = QDoubleSpinBox()
self.vel_z_spin.setRange(0.01, 10.0)
self.vel_z_spin.setDecimals(3)
self.vel_z_spin.setSingleStep(0.1)
self.vel_z_spin.setValue(1.0)
vel_layout.addWidget(self.vel_z_spin, 2, 1)
self.apply_vel_btn = QPushButton("Apply Velocity")
self.apply_vel_btn.clicked.connect(self.apply_velocity_settings)
self.apply_vel_btn.setEnabled(False)
vel_layout.addWidget(self.apply_vel_btn, 3, 0, 1, 2)
vel_group.setLayout(vel_layout)
layout.addWidget(vel_group)
# Acceleration settings
accel_group = QGroupBox("Acceleration (mm/s²)")
accel_layout = QGridLayout()
accel_layout.addWidget(QLabel("X Axis:"), 0, 0)
self.accel_x_spin = QDoubleSpinBox()
self.accel_x_spin.setRange(0.1, 100.0)
self.accel_x_spin.setDecimals(2)
self.accel_x_spin.setSingleStep(1.0)
self.accel_x_spin.setValue(5.0)
accel_layout.addWidget(self.accel_x_spin, 0, 1)
accel_layout.addWidget(QLabel("Y Axis:"), 1, 0)
self.accel_y_spin = QDoubleSpinBox()
self.accel_y_spin.setRange(0.1, 100.0)
self.accel_y_spin.setDecimals(2)
self.accel_y_spin.setSingleStep(1.0)
self.accel_y_spin.setValue(5.0)
accel_layout.addWidget(self.accel_y_spin, 1, 1)
accel_layout.addWidget(QLabel("Z Axis:"), 2, 0)
self.accel_z_spin = QDoubleSpinBox()
self.accel_z_spin.setRange(0.1, 100.0)
self.accel_z_spin.setDecimals(2)
self.accel_z_spin.setSingleStep(1.0)
self.accel_z_spin.setValue(5.0)
accel_layout.addWidget(self.accel_z_spin, 2, 1)
self.apply_accel_btn = QPushButton("Apply Acceleration")
self.apply_accel_btn.clicked.connect(self.apply_acceleration_settings)
self.apply_accel_btn.setEnabled(False)
accel_layout.addWidget(self.apply_accel_btn, 3, 0, 1, 2)
accel_group.setLayout(accel_layout)
layout.addWidget(accel_group)
# Trigger settings
trigger_group = QGroupBox("Trigger Configuration")
trigger_layout = QGridLayout()
trigger_layout.addWidget(QLabel("Axis:"), 0, 0)
self.trigger_axis_combo = QComboBox()
self.trigger_axis_combo.addItems(["X", "Y", "Z"])
trigger_layout.addWidget(self.trigger_axis_combo, 0, 1)
trigger_layout.addWidget(QLabel("Mode:"), 1, 0)
self.trigger_mode_combo = QComboBox()
self.trigger_mode_combo.addItems([
"Disabled",
"In/Out Relative Move",
"In/Out Absolute Move",
"In/Out Home",
"In/Out Stop",
"Out Only",
"Out Position"
])
trigger_layout.addWidget(self.trigger_mode_combo, 1, 1)
trigger_layout.addWidget(QLabel("Polarity:"), 2, 0)
self.trigger_polarity_combo = QComboBox()
self.trigger_polarity_combo.addItems(["Active High", "Active Low"])
trigger_layout.addWidget(self.trigger_polarity_combo, 2, 1)
self.apply_trigger_btn = QPushButton("Apply Trigger Settings")
self.apply_trigger_btn.clicked.connect(self.apply_trigger_settings)
self.apply_trigger_btn.setEnabled(False)
trigger_layout.addWidget(self.apply_trigger_btn, 3, 0, 1, 2)
trigger_group.setLayout(trigger_layout)
layout.addWidget(trigger_group)
# Save/Load buttons
buttons_layout = QHBoxLayout()
self.load_settings_btn = QPushButton("Load Settings")
self.load_settings_btn.clicked.connect(self.load_settings)
buttons_layout.addWidget(self.load_settings_btn)
self.save_settings_btn = QPushButton("Save Settings")
self.save_settings_btn.clicked.connect(self.save_settings)
self.save_settings_btn.setEnabled(False)
buttons_layout.addWidget(self.save_settings_btn)
layout.addLayout(buttons_layout)
group.setLayout(layout)
return group
def create_status_group(self) -> QGroupBox:
"""Create status display group"""
group = QGroupBox("Status")
layout = QGridLayout()
# Connection status
layout.addWidget(QLabel("Connected:"), 0, 0)
self.connected_label = QLabel("No")
self.connected_label.setStyleSheet("font-weight: bold; color: red;")
layout.addWidget(self.connected_label, 0, 1)
# Homed status
layout.addWidget(QLabel("X Homed:"), 1, 0)
self.x_homed_label = QLabel("No")
layout.addWidget(self.x_homed_label, 1, 1)
layout.addWidget(QLabel("Y Homed:"), 2, 0)
self.y_homed_label = QLabel("No")
layout.addWidget(self.y_homed_label, 2, 1)
layout.addWidget(QLabel("Z Homed:"), 3, 0)
self.z_homed_label = QLabel("No")
layout.addWidget(self.z_homed_label, 3, 1)
# Position
layout.addWidget(QLabel("X Position:"), 1, 2)
self.x_pos_label = QLabel("0.000 mm")
layout.addWidget(self.x_pos_label, 1, 3)
layout.addWidget(QLabel("Y Position:"), 2, 2)
self.y_pos_label = QLabel("0.000 mm")
layout.addWidget(self.y_pos_label, 2, 3)
layout.addWidget(QLabel("Z Position:"), 3, 2)
self.z_pos_label = QLabel("0.000 mm")
layout.addWidget(self.z_pos_label, 3, 3)
# Ready/Moving status
layout.addWidget(QLabel("Stage Ready:"), 4, 0)
self.ready_label = QLabel("No")
layout.addWidget(self.ready_label, 4, 1)
layout.addWidget(QLabel("Moving:"), 4, 2)
self.moving_label = QLabel("No")
layout.addWidget(self.moving_label, 4, 3)
group.setLayout(layout)
return group
def create_log_group(self) -> QGroupBox:
"""Create log display group"""
group = QGroupBox("Log")
layout = QVBoxLayout()
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setMaximumHeight(150)
layout.addWidget(self.log_text)
# Clear log button
clear_btn = QPushButton("Clear Log")
clear_btn.clicked.connect(self.log_text.clear)
layout.addWidget(clear_btn)
group.setLayout(layout)
return group
# ==================== Connection Methods ====================
def refresh_devices(self):
"""Refresh list of available devices"""
self.log("Searching for ThorLabs devices...")
devices = ThorLabsStage.list_devices()
self.device_combo.clear()
if devices:
for device in devices:
label = f"{device['serial']} - {device['port']} ({device['description']})"
self.device_combo.addItem(label, device['serial'])
self.log(f"Found: {label}")
# Auto-fill serial number from first device
if self.device_combo.count() > 0:
self.serial_edit.setText(self.device_combo.currentData())
else:
self.log("No ThorLabs devices found")
def connect_stage(self):
"""Connect to stage"""
serial = self.serial_edit.text().strip()
if not serial:
self.log("ERROR: Please enter a serial number")
return
baudrate = int(self.baudrate_combo.currentText())
self.log(f"Connecting to device {serial} at {baudrate} baud...")
if self.stage.connect(serial, baudrate):
self.log("Successfully connected to stage")
self.connected_label.setText("Yes")
self.connected_label.setStyleSheet("font-weight: bold; color: green;")
# Enable controls
self.connect_btn.setEnabled(False)
self.disconnect_btn.setEnabled(True)
self.identify_btn.setEnabled(True)
self.home_all_btn.setEnabled(True)
self.home_x_btn.setEnabled(True)
self.home_y_btn.setEnabled(True)
self.home_z_btn.setEnabled(True)
self.move_abs_btn.setEnabled(True)
self.move_rel_btn.setEnabled(True)
self.stop_btn.setEnabled(True)
self.apply_vel_btn.setEnabled(True)
self.apply_accel_btn.setEnabled(True)
self.apply_trigger_btn.setEnabled(True)
self.save_settings_btn.setEnabled(True)
# Load current settings into UI
self.load_settings_to_ui()
else:
self.log("ERROR: Failed to connect to stage")
def disconnect_stage(self):
"""Disconnect from stage"""
self.log("Disconnecting from stage...")
if self.stage.disconnect():
self.log("Disconnected successfully")
self.connected_label.setText("No")
self.connected_label.setStyleSheet("font-weight: bold; color: red;")
# Disable controls
self.connect_btn.setEnabled(True)
self.disconnect_btn.setEnabled(False)
self.identify_btn.setEnabled(False)
self.home_all_btn.setEnabled(False)
self.home_x_btn.setEnabled(False)
self.home_y_btn.setEnabled(False)
self.home_z_btn.setEnabled(False)
self.move_abs_btn.setEnabled(False)
self.move_rel_btn.setEnabled(False)
self.stop_btn.setEnabled(False)
self.apply_vel_btn.setEnabled(False)
self.apply_accel_btn.setEnabled(False)
self.apply_trigger_btn.setEnabled(False)
self.save_settings_btn.setEnabled(False)
else:
self.log("ERROR: Failed to disconnect")
def identify_stage(self):
"""Flash LEDs to identify controller"""
self.log("Flashing LEDs for identification...")
if self.stage.identify():
self.log("Identification command sent")
else:
self.log("ERROR: Failed to send identify command")
# ==================== Homing Methods ====================
def home_all(self):
"""Home all axes"""
self.log("Homing all axes...")
if self.stage.home_all_axes(wait=False):
self.log("Homing started for all axes")
else:
self.log("ERROR: Failed to start homing")
def home_axis(self, axis: str):
"""Home specific axis"""
self.log(f"Homing {axis} axis...")
if self.stage.home_axis(axis, wait=False):
self.log(f"{axis} axis homing started")
else:
self.log(f"ERROR: Failed to home {axis} axis")
# ==================== Motion Methods ====================
def move_absolute(self):
"""Move to absolute position"""
x = self.abs_x_spin.value()
y = self.abs_y_spin.value()
z = self.abs_z_spin.value()
self.log(f"Moving to absolute position: X={x}, Y={y}, Z={z}")
if self.stage.move_absolute(x=x, y=y, z=z, wait=False):
self.log("Absolute move started")
else:
self.log("ERROR: Failed to start absolute move")
def move_relative(self):
"""Move relative distance"""
dx = self.rel_x_spin.value()
dy = self.rel_y_spin.value()
dz = self.rel_z_spin.value()
self.log(f"Moving relative: dX={dx}, dY={dy}, dZ={dz}")
if self.stage.move_relative(dx=dx, dy=dy, dz=dz, wait=False):
self.log("Relative move started")
else:
self.log("ERROR: Failed to start relative move")
def stop_all(self):
"""Stop all motion"""
self.log("STOPPING ALL MOTION")
if self.stage.stop_all(immediate=True):
self.log("Stop command sent")
else:
self.log("ERROR: Failed to send stop command")
# ==================== Status Update ====================
def update_status(self):
"""Update status display"""
if not self.stage.is_connected():
return
try:
# Get status
status = self.stage.get_status()
position = self.stage.get_position()
# Update homed status
self.x_homed_label.setText("Yes" if status.get('x_homed') else "No")
self.x_homed_label.setStyleSheet(
"color: green;" if status.get('x_homed') else "color: red;"
)
self.y_homed_label.setText("Yes" if status.get('y_homed') else "No")
self.y_homed_label.setStyleSheet(
"color: green;" if status.get('y_homed') else "color: red;"
)
self.z_homed_label.setText("Yes" if status.get('z_homed') else "No")
self.z_homed_label.setStyleSheet(
"color: green;" if status.get('z_homed') else "color: red;"
)
# Update position
self.x_pos_label.setText(f"{position.get('x', 0.0):.3f} mm")
self.y_pos_label.setText(f"{position.get('y', 0.0):.3f} mm")
self.z_pos_label.setText(f"{position.get('z', 0.0):.3f} mm")
# Update ready/moving status
self.ready_label.setText("Yes" if status.get('ready') else "No")
self.ready_label.setStyleSheet(
"color: green; font-weight: bold;" if status.get('ready')
else "color: orange;"
)
self.moving_label.setText("Yes" if status.get('moving') else "No")
self.moving_label.setStyleSheet(
"color: orange; font-weight: bold;" if status.get('moving')
else "color: green;"
)
except Exception as e:
self.log(f"ERROR: Failed to update status: {e}")
# ==================== Settings Methods ====================
def load_settings_to_ui(self):
"""Load current settings from stage into UI"""
if not self.stage.is_connected():
return
try:
# Get current settings
velocities = self.stage.settings.get_all_velocities()
accelerations = self.stage.settings.get_all_accelerations()
# Update velocity spinboxes
self.vel_x_spin.setValue(velocities['x_axis'])
self.vel_y_spin.setValue(velocities['y_axis'])
self.vel_z_spin.setValue(velocities['z_axis'])
# Update acceleration spinboxes
self.accel_x_spin.setValue(accelerations['x_axis'])
self.accel_y_spin.setValue(accelerations['y_axis'])
self.accel_z_spin.setValue(accelerations['z_axis'])
# Update trigger settings for X axis (default)
trigger_config = self.stage.settings.get_trigger_config('x_axis')
self.trigger_mode_combo.setCurrentIndex(trigger_config.get('mode', 0))
polarity = trigger_config.get('polarity', 0x01)
self.trigger_polarity_combo.setCurrentIndex(0 if polarity == 0x01 else 1)
self.log("Settings loaded into UI")
except Exception as e:
self.log(f"ERROR: Failed to load settings to UI: {e}")
def apply_velocity_settings(self):
"""Apply velocity settings to stage"""
self.log("Applying velocity settings...")
x = self.vel_x_spin.value()
y = self.vel_y_spin.value()
z = self.vel_z_spin.value()
if self.stage.configure_velocity(x=x, y=y, z=z, save=False):
self.log(f"Velocity settings applied: X={x}, Y={y}, Z={z} mm/s")
else:
self.log("ERROR: Failed to apply velocity settings")
def apply_acceleration_settings(self):
"""Apply acceleration settings to stage"""
self.log("Applying acceleration settings...")
x = self.accel_x_spin.value()
y = self.accel_y_spin.value()
z = self.accel_z_spin.value()
if self.stage.configure_acceleration(x=x, y=y, z=z, save=False):
self.log(f"Acceleration settings applied: X={x}, Y={y}, Z={z} mm/s²")
else:
self.log("ERROR: Failed to apply acceleration settings")
def apply_trigger_settings(self):
"""Apply trigger settings to stage"""
self.log("Applying trigger settings...")
axis = self.trigger_axis_combo.currentText()
mode_index = self.trigger_mode_combo.currentIndex()
# Map mode index to TriggerMode enum
mode_map = {
0: TriggerMode.DISABLED,
1: TriggerMode.IN_OUT_RELATIVE_MOVE,
2: TriggerMode.IN_OUT_ABSOLUTE_MOVE,
3: TriggerMode.IN_OUT_HOME,
4: TriggerMode.IN_OUT_STOP,
5: TriggerMode.OUT_ONLY,
6: TriggerMode.OUT_POSITION
}
mode = mode_map.get(mode_index, TriggerMode.DISABLED)
# Get polarity
polarity = 0x01 if self.trigger_polarity_combo.currentIndex() == 0 else 0x02
if self.stage.configure_trigger(axis, mode, polarity=polarity, save=False):
mode_name = self.trigger_mode_combo.currentText()
pol_name = self.trigger_polarity_combo.currentText()
self.log(f"Trigger settings applied: {axis} axis, {mode_name}, {pol_name}")
else:
self.log("ERROR: Failed to apply trigger settings")
def save_settings(self):
"""Save current settings to file"""
self.log("Saving settings to file...")
if self.stage.save_current_settings():
self.log("Settings saved successfully")
QMessageBox.information(self, "Settings Saved",
"Stage settings have been saved successfully.")
else:
self.log("ERROR: Failed to save settings")
QMessageBox.warning(self, "Save Failed",
"Failed to save stage settings.")
def load_settings(self):
"""Load settings from file"""
self.log("Loading settings from file...")
if self.stage.reload_settings():
self.log("Settings loaded successfully")
# Update UI with loaded settings
if self.stage.is_connected():
self.load_settings_to_ui()
# Apply to hardware if connected
if self.stage.is_connected():
self.stage.apply_startup_settings()
QMessageBox.information(self, "Settings Loaded",
"Stage settings have been loaded successfully.")
else:
self.log("WARNING: No settings file found, using defaults")
QMessageBox.information(self, "No Settings Found",
"No settings file found. Using default values.")
# ==================== Logging ====================
def log(self, message: str):
"""Add message to log"""
timestamp = time.strftime("%H:%M:%S")
self.log_text.append(f"[{timestamp}] {message}")
# Auto-scroll to bottom
scrollbar = self.log_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def closeEvent(self, event):
"""Handle window close event"""
if self.stage.is_connected():
reply = QMessageBox.question(
self, 'Disconnect Stage',
'Stage is still connected. Disconnect before closing?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes
)
if reply == QMessageBox.StandardButton.Yes:
self.stage.disconnect()
event.accept()
else:
event.ignore()
else:
event.accept()
def main():
"""Main application entry point"""
app = QApplication(sys.argv)
window = StageTestWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Thomas K Ales
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-668
View File
@@ -1,668 +0,0 @@
# BBD202/203 Motion Controller Library
Python library for controlling Thorlabs BBD202/BBD203 motion controllers via FTDI interface using the APT protocol.
## Features
- Full control of X and Y axes
- Absolute and relative positioning
- Configurable velocity and acceleration
- Automatic position tracking
- Status monitoring with convenient properties
- Thread-safe operation
- Context manager support for automatic cleanup
## Requirements
The FTDI D2XX driver is required for this code to work correctly. You will need to disable linux's
ftdi_sio module in order to use it. I am not sure why, and I have no intent of diagnosing it.
```bash
pip install pyftdi
```
## Quick Start
```python
from bbd203_controller import MotionController
# Connect using context manager (automatic cleanup)
with MotionController() as mc:
# Print hardware information
print(f"Model: {mc.get_model()}")
print(f"Serial: {mc.get_serial_number()}")
print(f"Firmware: {mc.get_firmware_version()}")
# Enable and home X-axis
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
mc.home_x_axis(timeout=20.0)
# Move to absolute position
mc.set_velocity_params(mc.DEST_X_AXIS,
min_velocity=0.0,
acceleration=100.0,
max_velocity=50.0)
mc.set_move_abs_params(mc.DEST_X_AXIS, absolute_position=25.0)
result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
print(f"Final position: {result['position']:.3f} mm")
```
## Connection Management
### Basic Connection
```python
from bbd203_controller import MotionController
# Manual connection
mc = MotionController()
mc.connect()
# Use the controller...
mc.disconnect()
```
### Using Context Manager (Recommended)
```python
# Automatic connection and cleanup
with MotionController() as mc:
# Use the controller...
pass # Automatically disconnects when exiting context
```
### Custom FTDI URL
```python
mc = MotionController(url='ftdi://0x0403:0xfaf0/1', baudrate=115200)
```
## Axis Control
### Enabling Axes
```python
# Enable X-axis
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
# Enable Y-axis
mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True)
# Check if enabled
is_enabled = mc.get_channel_enable_state(mc.DEST_X_AXIS)
```
### Homing
```python
# Home X-axis (blocks until complete)
if mc.home_x_axis(timeout=20.0):
print(f"X-axis homed at position: {mc.position_x} mm")
else:
print("Homing timed out")
# Home Y-axis
mc.home_y_axis(timeout=20.0)
```
After homing, the position automatically resets to 0 mm.
## Motion Control
### Setting Velocity Parameters
```python
# Set velocity parameters for X-axis
mc.set_velocity_params(
mc.DEST_X_AXIS,
min_velocity=0.0, # mm/s
acceleration=100.0, # mm/s²
max_velocity=50.0 # mm/s
)
# Get current velocity parameters
params = mc.get_velocity_params(mc.DEST_X_AXIS)
print(f"Max velocity: {params['max_velocity']:.2f} mm/s")
print(f"Acceleration: {params['acceleration']:.2f} mm/s²")
```
### Setting Acceleration Only
```python
# Change just the acceleration, preserving velocity settings
mc.set_acceleration(mc.DEST_X_AXIS, 75.0)
# Get just the acceleration value
accel = mc.get_acceleration(mc.DEST_X_AXIS)
```
### Absolute Moves
```python
# Move to absolute position
mc.set_move_abs_params(mc.DEST_X_AXIS, absolute_position=30.0)
result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
if result:
print(f"Moved to: {result['position']:.3f} mm")
print(f"Status: 0x{result['status_bits']:08X}")
```
### Relative Moves
```python
# Move relative to current position
mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=5.0)
result = mc.move_relative(mc.DEST_X_AXIS, timeout=30.0)
# Move backwards
mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=-2.5)
mc.move_relative(mc.DEST_X_AXIS, timeout=30.0)
```
### Stopping Motion
```python
# Controlled stop (gradual deceleration)
mc.stop_x_axis(stop_mode=mc.StopMode.CONTROLLED, wait_for_stopped=True)
# Immediate stop
mc.stop_x_axis(stop_mode=mc.StopMode.IMMEDIATE, wait_for_stopped=True)
# Stop both axes simultaneously
results = mc.stop_all_axes(stop_mode=mc.StopMode.CONTROLLED)
```
## Position Tracking
### Reading Current Position
```python
# Query position from controller (blocking)
position = mc.get_position(mc.DEST_X_AXIS, timeout=5.0)
print(f"X position: {position:.3f} mm")
# Access cached position (non-blocking)
x_pos = mc.position_x
y_pos = mc.position_y
# Get encoder counts (raw values)
x_counts = mc.encoder_count_x
```
## Status Monitoring
### Using Status Properties
```python
# Check various status flags
print(f"X-axis enabled: {mc.is_enabled_x}")
print(f"X-axis homed: {mc.is_homed_x}")
print(f"X-axis in motion: {mc.is_in_motion_x}")
print(f"X-axis settled: {mc.is_settled_x}")
print(f"X-axis has errors: {mc.has_errors_x}")
print(f"Power OK: {mc.power_ok_x}")
```
### Decoding Status Bits
```python
result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
if result:
status_bits = result['status_bits']
# Get human-readable description
description = MotionController.get_status_description(status_bits)
print(description)
# Check for errors
if MotionController.has_errors(status_bits):
print("ERROR: Motion completed with errors!")
# Check motion state
if MotionController.is_settled(status_bits):
print("Stage is settled at target position")
```
### Available Status Checks
- `is_enabled_x` / `is_enabled_y` - Motor output enabled
- `is_homed_x` / `is_homed_y` - Axis has been homed
- `is_homing_x` / `is_homing_y` - Currently homing
- `is_in_motion_x` / `is_in_motion_y` - Currently moving
- `is_settled_x` / `is_settled_y` - Settled at target
- `is_tracking_x` / `is_tracking_y` - Within tracking window
- `is_connected_x` / `is_connected_y` - Motor recognized
- `has_errors_x` / `has_errors_y` - Any error condition
- `power_ok_x` / `power_ok_y` - Power supply OK
- `is_active_x` / `is_active_y` - Executing motion command
- `at_cw_limit_x` / `at_cw_limit_y` - At clockwise limit
- `at_ccw_limit_x` / `at_ccw_limit_y` - At counter-clockwise limit
## Multi-Axis Operations
### Simultaneous Moves (Using Threading)
```python
import threading
def move_x():
mc.set_move_abs_params(mc.DEST_X_AXIS, 50.0)
mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
def move_y():
mc.set_move_abs_params(mc.DEST_Y_AXIS, 30.0)
mc.move_absolute(mc.DEST_Y_AXIS, timeout=30.0)
# Start both moves in parallel
x_thread = threading.Thread(target=move_x)
y_thread = threading.Thread(target=move_y)
x_thread.start()
y_thread.start()
# Wait for both to complete
x_thread.join()
y_thread.join()
print(f"Final position: ({mc.position_x:.2f}, {mc.position_y:.2f}) mm")
```
## Hardware Information
```python
with MotionController() as mc:
# Individual fields
print(f"Serial Number: {mc.get_serial_number()}")
print(f"Model: {mc.get_model()}")
print(f"Firmware: {mc.get_firmware_version()}")
print(f"Hardware Version: {mc.get_hw_version()}")
print(f"Number of Channels: {mc.get_num_channels()}")
# All info at once
info = mc.get_hw_info()
for key, value in info.items():
print(f"{key}: {value}")
```
## Complete Examples
### Example 1: Simple Linear Move
```python
from bbd203_controller import MotionController
import time
with MotionController() as mc:
# Enable and home X-axis
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
time.sleep(0.5)
print("Homing X-axis...")
mc.home_x_axis(timeout=20.0)
print(f"Homed at {mc.position_x} mm")
# Set velocity for smooth motion
mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 50.0, 25.0)
# Move to 40mm
print("Moving to 40mm...")
mc.set_move_abs_params(mc.DEST_X_AXIS, 40.0)
result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
if result and not mc.has_errors_x:
print(f"Successfully moved to {result['position']:.3f} mm")
else:
print("Move failed or has errors")
```
### Example 2: Square Pattern with Two Axes
```python
from bbd203_controller import MotionController
import threading
import time
def move_to_position(mc, x, y, label):
"""Move to (x, y) with both axes moving simultaneously."""
print(f"Moving to {label}: ({x}, {y}) mm")
# Set parameters for both axes
mc.set_move_abs_params(mc.DEST_X_AXIS, x)
mc.set_move_abs_params(mc.DEST_Y_AXIS, y)
time.sleep(0.1)
# Execute moves in parallel
results = [None, None]
def move_x():
results[0] = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
def move_y():
results[1] = mc.move_absolute(mc.DEST_Y_AXIS, timeout=30.0)
x_thread = threading.Thread(target=move_x)
y_thread = threading.Thread(target=move_y)
x_thread.start()
y_thread.start()
x_thread.join()
y_thread.join()
if results[0] and results[1]:
print(f" Reached ({results[0]['position']:.2f}, {results[1]['position']:.2f}) mm")
return True
return False
with MotionController() as mc:
# Enable both axes
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True)
time.sleep(0.5)
# Home both axes
print("Homing axes...")
mc.home_x_axis(timeout=20.0)
mc.home_y_axis(timeout=20.0)
# Set velocity for both axes
velocity = 50.0
acceleration = 100.0
mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, acceleration, velocity)
mc.set_velocity_params(mc.DEST_Y_AXIS, 0.0, acceleration, velocity)
# Define 20mm square centered at (55, 37.5)
center_x, center_y = 55.0, 37.5
half_size = 10.0
waypoints = [
(center_x - half_size, center_y - half_size, "Bottom Left"),
(center_x + half_size, center_y - half_size, "Bottom Right"),
(center_x + half_size, center_y + half_size, "Top Right"),
(center_x - half_size, center_y + half_size, "Top Left"),
(center_x, center_y, "Center"),
]
# Execute square pattern
for x, y, label in waypoints:
if not move_to_position(mc, x, y, label):
print(f"Failed at {label}")
break
time.sleep(0.5)
print("Square pattern complete!")
```
### Example 3: Velocity Ramping Test
```python
from bbd203_controller import MotionController
import time
with MotionController() as mc:
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
time.sleep(0.5)
mc.home_x_axis(timeout=20.0)
# Test at different velocities
test_velocities = [10.0, 25.0, 50.0, 100.0]
move_distance = 20.0
for velocity in test_velocities:
print(f"\n--- Testing at {velocity} mm/s ---")
# Set velocity parameters
mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 100.0, velocity)
# Move forward
mc.set_move_abs_params(mc.DEST_X_AXIS, move_distance)
start_time = time.time()
result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
elapsed = time.time() - start_time
if result:
print(f" Moved {move_distance}mm in {elapsed:.2f}s")
print(f" Average speed: {move_distance/elapsed:.2f} mm/s")
time.sleep(0.5)
# Move back to start
mc.set_move_abs_params(mc.DEST_X_AXIS, 0.0)
mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
time.sleep(0.5)
```
### Example 4: Position Monitoring During Move
```python
from bbd203_controller import MotionController
import threading
import time
with MotionController() as mc:
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
time.sleep(0.5)
mc.home_x_axis(timeout=20.0)
# Set slow velocity for visible monitoring
mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 50.0, 10.0)
# Start move in background thread
move_complete = threading.Event()
def do_move():
mc.set_move_abs_params(mc.DEST_X_AXIS, 50.0)
mc.move_absolute(mc.DEST_X_AXIS, timeout=60.0)
move_complete.set()
move_thread = threading.Thread(target=do_move)
move_thread.start()
# Monitor position while moving
print("Position monitoring:")
while not move_complete.is_set():
# Request current position
pos = mc.get_position(mc.DEST_X_AXIS, timeout=1.0)
if pos is not None:
print(f" Current position: {pos:.3f} mm, "
f"In motion: {mc.is_in_motion_x}, "
f"Settled: {mc.is_settled_x}")
time.sleep(0.5)
move_thread.join()
print(f"Move complete! Final position: {mc.position_x:.3f} mm")
```
## Constants and Enumerations
### Axis Destinations
```python
mc.DEST_CONTROLLER # 0x11 - Controller/motherboard
mc.DEST_X_AXIS # 0x21 - X-axis
mc.DEST_Y_AXIS # 0x22 - Y-axis
```
### Stop Modes
```python
from bbd203_controller import StopMode
StopMode.IMMEDIATE # 1 - Instant stop
StopMode.CONTROLLED # 2 - Controlled deceleration (default)
```
### Jog Modes
```python
from bbd203_controller import JogMode
JogMode.CONTINUOUS # 1 - Continuous jogging
JogMode.SINGLE_STEP # 2 - Single step jogging
```
### Channel Enable States
```python
from bbd203_controller import ChannelEnableState
ChannelEnableState.DISABLED # 0x02
ChannelEnableState.ENABLED # 0x01
```
## Scaling Factors
The library handles all unit conversions automatically:
- **Position**: 20,000 encoder counts per mm
- **Velocity**: 13,421.77 counts per mm/s
- **Acceleration**: 13.744 counts per mm/s²
## Error Handling
```python
from bbd203_controller import MotionController
try:
with MotionController() as mc:
# Invalid axis destination
mc.set_channel_enable_state(0x99, enabled=True)
except ValueError as e:
print(f"ValueError: {e}")
try:
with MotionController() as mc:
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
mc.home_x_axis(timeout=5.0) # Too short timeout
if not mc.is_homed_x:
print("Homing failed - axis not homed")
except Exception as e:
print(f"Error: {e}")
```
## Advanced Features
### Message Callbacks
```python
from bbd203_controller import MotionController, AptMessage, MsgId
def on_move_stopped(msg: AptMessage):
print(f"Axis stopped unexpectedly!")
print(f"Source: 0x{msg.source:02X}")
with MotionController() as mc:
# Register callback for stop events
mc.register_callback(MsgId.MOT_MOVE_STOPPED, on_move_stopped)
# Your motion code here...
# Unregister when done
mc.unregister_callback(MsgId.MOT_MOVE_STOPPED, on_move_stopped)
```
### Direct Message Access
```python
# Wait for a specific message type
msg = mc.wait_for_message(MsgId.MOT_MOVE_COMPLETED, timeout=30.0)
# Get next message from queue
msg = mc.get_message(timeout=0.1)
# Get all queued messages
messages = mc.get_all_messages()
```
### Manual Connection Control
```python
mc = MotionController()
# Connect with updates disabled
mc.connect(enable_updates=False)
# Manually start/stop status updates
mc.start_update_messages()
# ...
mc.stop_update_messages()
mc.disconnect()
```
## Tips for Linear Scans
For performing linear scans at controlled speeds:
1. **Set velocity parameters** before each scan to ensure consistent motion
2. **Use absolute moves** with pre-calculated waypoints for accuracy
3. **For continuous scanning**: Execute moves sequentially without waiting
4. **For synchronized 2-axis moves**: Use threading (see examples above)
5. **Monitor position** during moves if needed for data acquisition timing
### Simple 1D Linear Scan
```python
with MotionController() as mc:
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
mc.home_x_axis(timeout=20.0)
# Scan parameters
start_pos = 10.0 # mm
end_pos = 90.0 # mm
step_size = 2.0 # mm
scan_speed = 20.0 # mm/s
# Set velocity for consistent speed
mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 100.0, scan_speed)
# Execute scan
position = start_pos
while position <= end_pos:
mc.set_move_abs_params(mc.DEST_X_AXIS, position)
result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0)
if result:
# Acquire data at this position
print(f"Scan point at {result['position']:.3f} mm")
# Your data acquisition code here...
position += step_size
```
## Troubleshooting
### Controller Not Responding
If the controller stops responding after many commands:
- The library automatically sends ACK messages every second
- This is handled internally and should not require user intervention
### Moves Timing Out
- Increase the `timeout` parameter on move commands
- Check that velocity and acceleration are set appropriately
- Ensure the axis is enabled and homed
### Position Inaccurate After Homing
- Position automatically resets to 0 mm after homing completes
- Always wait for homing to complete before issuing move commands
- Check `is_homed_x` / `is_homed_y` properties to verify
### Unexpected Stops
- Register a callback for `MsgId.MOT_MOVE_STOPPED` to detect stop events
- Check error flags in status bits
- Ensure no limit switches are being triggered
## License
MIT License
## Author
Generated for BBD202/BBD203 motion controller control via APT protocol.
-1
View File
@@ -1 +0,0 @@
msodev/
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""
Test script for acquisition mode get/set functions.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test acquisition mode functions on oscilloscope at 192.168.10.105"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip)
try:
# Connect to scope
scope.connect()
print(f"✓ Connected to {scope.identify()}\n")
# Get current acquisition mode
print("Getting current acquisition mode...")
current_mode = scope.get_acquire_mode()
print(f"✓ Current acquisition mode: {current_mode}\n")
# Test setting different acquisition modes
print("Testing acquisition mode changes:")
test_modes = ['SAMple', 'HIRes', 'AVErage', 'PEAKdetect', 'ENVelope']
for mode in test_modes:
print(f"\n Setting mode to: {mode}")
scope.set_acquire_mode(mode)
# Verify the change
actual_mode = scope.get_acquire_mode()
if actual_mode == mode:
print(f" ✓ Verified: {actual_mode}")
else:
print(f" ✗ Mismatch: expected {mode}, got {actual_mode}")
# Restore original mode
print(f"\nRestoring original mode: {current_mode}")
scope.set_acquire_mode(current_mode)
print(f"✓ Restored to: {scope.get_acquire_mode()}")
# Test invalid mode
print("\nTesting invalid mode (should raise ValueError)...")
try:
scope.set_acquire_mode("INVALID")
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
except Exception as e:
print(f"✗ Error: {type(e).__name__}: {e}")
finally:
if scope.is_connected:
scope.disconnect()
print("\n✓ Disconnected from oscilloscope")
if __name__ == "__main__":
main()
-257
View File
@@ -1,257 +0,0 @@
#!/usr/bin/env python3
"""
Test script for channel control functionality.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test channel control functions on oscilloscope at 192.168.10.105"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip)
try:
# Connect to scope
scope.connect()
print(f"Connected to {scope.identify()}\n")
# Test with CH1
test_channel = 1
print(f"=== Testing Channel {test_channel} ===\n")
# Query all channel parameters
print("Querying all channel parameters...")
all_params = scope.query_channel(test_channel)
print(f"CH{test_channel} parameters: {all_params[:100]}...\n")
# Get current settings to restore later
print("Getting current channel settings...")
current_bandwidth = scope.get_channel_bandwidth(test_channel)
current_coupling = scope.get_channel_coupling(test_channel)
current_termination = scope.get_channel_termination(test_channel)
current_scale = scope.get_channel_scale(test_channel)
current_offset = scope.get_channel_offset(test_channel)
current_position = scope.get_channel_position(test_channel)
current_label_name = scope.get_channel_label_name(test_channel)
current_label_color = scope.get_channel_label_color(test_channel)
current_label_font_size = scope.get_channel_label_font_size(test_channel)
current_label_font_type = scope.get_channel_label_font_type(test_channel)
current_label_xpos = scope.get_channel_label_xpos(test_channel)
current_label_ypos = scope.get_channel_label_ypos(test_channel)
print(f" Current bandwidth: {current_bandwidth}")
print(f" Current coupling: {current_coupling}")
print(f" Current termination: {current_termination} ohms")
print(f" Current scale: {current_scale} V/div")
print(f" Current offset: {current_offset} V")
print(f" Current position: {current_position} divisions")
print(f" Current label name: {current_label_name}")
print(f" Current label color: {current_label_color}")
print(f" Current label font size: {current_label_font_size} pt")
print(f" Current label font type: {current_label_font_type}")
print(f" Current label X position: {current_label_xpos} px")
print(f" Current label Y position: {current_label_ypos} px\n")
# Test coupling modes
print("Testing coupling modes:")
for coupling in ['DC', 'AC']:
print(f" Setting coupling to {coupling}...")
scope.set_channel_coupling(test_channel, coupling)
actual = scope.get_channel_coupling(test_channel)
print(f" Actual coupling: {actual}")
# Test termination
print("\nTesting termination settings:")
for term in [50, 1000000]:
print(f" Setting termination to {term} ohms...")
scope.set_channel_termination(test_channel, term)
actual = scope.get_channel_termination(test_channel)
print(f" Actual termination: {actual} ohms")
# Test vertical scale
print("\nTesting vertical scale:")
test_scales = [0.1, 0.5, 1.0, 2.0]
for scale in test_scales:
print(f" Setting scale to {scale} V/div...")
scope.set_channel_scale(test_channel, scale)
actual = scope.get_channel_scale(test_channel)
print(f" Actual scale: {actual} V/div")
# Test vertical offset
print("\nTesting vertical offset:")
test_offsets = [0.0, 0.5, -0.5, 1.0]
for offset in test_offsets:
print(f" Setting offset to {offset} V...")
scope.set_channel_offset(test_channel, offset)
actual = scope.get_channel_offset(test_channel)
print(f" Actual offset: {actual} V")
# Test vertical position
print("\nTesting vertical position:")
test_positions = [0.0, 1.0, -1.0, 2.5]
for position in test_positions:
print(f" Setting position to {position} divisions...")
scope.set_channel_position(test_channel, position)
actual = scope.get_channel_position(test_channel)
print(f" Actual position: {actual} divisions")
# Test label name
print("\nTesting label name:")
test_names = ["Test Signal", "CH1-Custom", "Probe Input"]
for name in test_names:
print(f" Setting label to '{name}'...")
scope.set_channel_label_name(test_channel, name)
actual = scope.get_channel_label_name(test_channel)
print(f" Actual label: {actual}")
# Test label color
print("\nTesting label color:")
test_colors = ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"]
for color in test_colors:
print(f" Setting color to {color}...")
scope.set_channel_label_color(test_channel, color)
actual = scope.get_channel_label_color(test_channel)
print(f" Actual color: {actual}")
# Test label font size
print("\nTesting label font size:")
test_sizes = [10, 12, 14, 16]
for size in test_sizes:
print(f" Setting font size to {size} pt...")
scope.set_channel_label_font_size(test_channel, size)
actual = scope.get_channel_label_font_size(test_channel)
print(f" Actual font size: {actual} pt")
# Test label font type
print("\nTesting label font type:")
test_fonts = ["Arial", "Helvetica", "Courier"]
for font in test_fonts:
print(f" Setting font to {font}...")
scope.set_channel_label_font_type(test_channel, font)
actual = scope.get_channel_label_font_type(test_channel)
print(f" Actual font: {actual}")
# Test label position
print("\nTesting label X position:")
test_xpos = [100, 200, 300]
for xpos in test_xpos:
print(f" Setting X position to {xpos} px...")
scope.set_channel_label_xpos(test_channel, xpos)
actual = scope.get_channel_label_xpos(test_channel)
print(f" Actual X position: {actual} px")
print("\nTesting label Y position:")
test_ypos = [50, 100, 150]
for ypos in test_ypos:
print(f" Setting Y position to {ypos} px...")
scope.set_channel_label_ypos(test_channel, ypos)
actual = scope.get_channel_label_ypos(test_channel)
print(f" Actual Y position: {actual} px")
# Test using string channel format
print("\nTesting with string channel format ('CH2'):")
print(" Setting CH2 coupling to AC...")
scope.set_channel_coupling('CH2', 'AC')
actual = scope.get_channel_coupling('CH2')
print(f" Actual CH2 coupling: {actual}")
print(" Setting CH2 scale to 0.5 V/div...")
scope.set_channel_scale('CH2', 0.5)
actual = scope.get_channel_scale('CH2')
print(f" Actual CH2 scale: {actual} V/div")
# Restore original settings
print(f"\nRestoring original settings for CH{test_channel}...")
scope.set_channel_coupling(test_channel, current_coupling)
scope.set_channel_termination(test_channel, current_termination)
scope.set_channel_scale(test_channel, current_scale)
scope.set_channel_offset(test_channel, current_offset)
scope.set_channel_position(test_channel, current_position)
scope.set_channel_label_name(test_channel, current_label_name)
scope.set_channel_label_color(test_channel, current_label_color)
scope.set_channel_label_font_size(test_channel, current_label_font_size)
scope.set_channel_label_font_type(test_channel, current_label_font_type)
scope.set_channel_label_xpos(test_channel, current_label_xpos)
scope.set_channel_label_ypos(test_channel, current_label_ypos)
print(f" Restored coupling: {scope.get_channel_coupling(test_channel)}")
print(f" Restored termination: {scope.get_channel_termination(test_channel)} ohms")
print(f" Restored scale: {scope.get_channel_scale(test_channel)} V/div")
# Test invalid inputs
print("\n=== Testing Error Handling ===\n")
print("Testing invalid channel number (should raise ValueError)...")
try:
scope.get_channel_coupling(5)
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid channel string (should raise ValueError)...")
try:
scope.get_channel_coupling('CH5')
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid coupling (should raise ValueError)...")
try:
scope.set_channel_coupling(1, 'INVALID')
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid termination (should raise ValueError)...")
try:
scope.set_channel_termination(1, 75)
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid scale (should raise ValueError)...")
try:
scope.set_channel_scale(1, -1.0)
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid color format (should raise ValueError)...")
try:
scope.set_channel_label_color(1, "FF0000") # Missing #
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid font size (should raise ValueError)...")
try:
scope.set_channel_label_font_size(1, -5)
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid X position (should raise ValueError)...")
try:
scope.set_channel_label_xpos(1, -10)
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\n=== All tests completed successfully! ===")
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("\nDisconnected from oscilloscope")
if __name__ == "__main__":
main()
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""
Test script to connect to a Tektronix oscilloscope via raw sockets.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test connection to oscilloscope at 192.168.10.105"""
scope_ip = "192.168.10.105"
scope_port = 4000
print(f"Attempting to connect to oscilloscope at {scope_ip}:{scope_port}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, port=scope_port, timeout=5.0)
try:
# Attempt connection
scope.connect()
print(f"✓ Successfully connected to {scope_ip}:{scope_port}")
print(f"✓ Connection status: {scope.is_connected}")
# Get instrument identification
print("\nQuerying instrument identification...")
idn = scope.identify()
print(f"✓ Instrument ID: {idn}")
# Test a simple query
print("\nTesting SCPI query...")
response = scope.query("*OPT?")
print(f"✓ Installed options: {response}")
# Test a write command
print("\nTesting SCPI write command...")
scope.write("*CLS")
print("✓ Cleared status registers")
except ValueError as e:
print(f"✗ Configuration error: {e}")
except ConnectionError as e:
print(f"✗ Connection failed: {e}")
print("\nTroubleshooting tips:")
print(" - Verify the oscilloscope IP address is correct")
print(" - Check network connectivity (try: ping 192.168.10.105)")
print(" - Ensure the oscilloscope has LXI/socket server enabled")
print(" - Verify port 4000 is correct (check scope network settings)")
except RuntimeError as e:
print(f"✗ Runtime error: {e}")
except Exception as e:
print(f"✗ Unexpected error: {type(e).__name__}: {e}")
finally:
# Always disconnect
if scope.is_connected:
scope.disconnect()
print("\n✓ Disconnected from oscilloscope")
else:
print("\n✗ Not connected")
if __name__ == "__main__":
main()
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""
Test script for FastFrame functionality.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test FastFrame functions on oscilloscope at 192.168.10.105"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip)
try:
# Connect to scope
scope.connect()
print(f"✓ Connected to {scope.identify()}\n")
# Get current FastFrame state
print("Getting current FastFrame state...")
current_state = scope.get_fastframe_state()
print(f"✓ Current FastFrame state: {current_state} ({'active' if current_state else 'off'})\n")
# Get current frame count
print("Getting current frame count...")
current_count = scope.get_fastframe_count()
print(f"✓ Current frame count: {current_count}\n")
# Test enabling FastFrame
print("Enabling FastFrame...")
scope.set_fastframe_state(1)
state = scope.get_fastframe_state()
print(f"✓ FastFrame state: {state} ({'active' if state else 'off'})\n")
# Test setting frame count
print("Setting frame count to 100...")
scope.set_fastframe_count(100)
count = scope.get_fastframe_count()
print(f"✓ Frame count: {count}\n")
# Test using boolean for state
print("Testing boolean state (True)...")
scope.set_fastframe_state(True)
state = scope.get_fastframe_state()
print(f"✓ FastFrame state: {state} ({'active' if state else 'off'})\n")
print("Testing boolean state (False)...")
scope.set_fastframe_state(False)
state = scope.get_fastframe_state()
print(f"✓ FastFrame state: {state} ({'active' if state else 'off'})\n")
# Test different frame counts
print("Testing different frame counts:")
test_counts = [10, 50, 200, 500]
for test_count in test_counts:
print(f" Setting count to {test_count}...")
scope.set_fastframe_count(test_count)
actual_count = scope.get_fastframe_count()
if actual_count == test_count:
print(f" ✓ Verified: {actual_count}")
else:
print(f" ✗ Mismatch: expected {test_count}, got {actual_count}")
# Restore original settings
print(f"\nRestoring original settings...")
scope.set_fastframe_state(current_state)
scope.set_fastframe_count(current_count)
print(f"✓ Restored FastFrame state: {scope.get_fastframe_state()}")
print(f"✓ Restored frame count: {scope.get_fastframe_count()}")
# Test invalid inputs
print("\nTesting invalid state (should raise ValueError)...")
try:
scope.set_fastframe_state(2)
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
print("\nTesting invalid frame count (should raise ValueError)...")
try:
scope.set_fastframe_count(-1)
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
except Exception as e:
print(f"✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("\n✓ Disconnected from oscilloscope")
if __name__ == "__main__":
main()
-292
View File
@@ -1,292 +0,0 @@
#!/usr/bin/env python3
"""
Test script for FastFrame waveform acquisition.
Tests acquiring 1000 FastFrame records from a 1MHz square wave on CH1.
Signal: 1MHz square wave, +250mV to -250mV
Records: 1000 frames, 2500 points each
"""
import time
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test FastFrame acquisition with 1MHz square wave on CH1"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=20.0)
try:
# Connect to scope
scope.connect()
print(f"Connected to {scope.identify()}\n")
print("=== Configuring Oscilloscope ===\n")
# Configure CH1 for the signal
print("Configuring CH1...")
print(" Setting vertical scale to 100mV/div...")
scope.set_channel_scale(1, 0.1) # 100mV/div for ±250mV signal
print(" Setting vertical offset to 0V...")
scope.set_channel_offset(1, 0.0)
print(" Setting coupling to DC...")
scope.set_channel_coupling(1, 'DC')
print(" Setting termination to 50 ohms...")
scope.set_channel_termination(1, 50)
# Verify settings
actual_scale = scope.get_channel_scale(1)
actual_offset = scope.get_channel_offset(1)
actual_coupling = scope.get_channel_coupling(1)
actual_term = scope.get_channel_termination(1)
print(f" Verified: {actual_scale}V/div, {actual_offset}V offset, {actual_coupling} coupling, {actual_term}Ω\n")
# Configure horizontal timebase
# For 1MHz square wave (1µs period), let's capture ~4 cycles (4µs)
# 4µs over 10 divisions = 400ns/div
print("Configuring horizontal timebase...")
print(" Setting time scale to 400ns/div (4µs total for ~4 cycles of 1MHz)...")
scope.set_time_scale(400e-9) # 400ns/div
print(" Setting record length to 2500 points...")
scope.set_record_length(2500)
# Verify settings
actual_time_scale = scope.get_time_scale()
actual_record_length = scope.get_record_length()
actual_sample_rate = scope.get_sample_rate()
print(f" Verified: {actual_time_scale*1e9:.0f}ns/div, {actual_record_length} points")
print(f" Sample rate: {actual_sample_rate/1e6:.1f} MS/s\n")
# Configure trigger
print("Configuring trigger...")
print(" Setting trigger source to CH1...")
scope.set_trigger_source('CH1')
print(" Setting trigger level to +100mV...")
scope.set_trigger_level(1, 0.1) # +100mV
print(" Setting trigger slope to rising...")
scope.set_trigger_slope('RISE')
print(" Setting trigger mode to NORMAL...")
scope.set_trigger_mode('NORMAL')
print(" Setting trigger coupling to DC...")
scope.set_trigger_coupling('DC')
# Verify trigger settings
actual_trigger_source = scope.get_trigger_source()
actual_trigger_level = scope.get_trigger_level(1)
actual_trigger_slope = scope.get_trigger_slope()
actual_trigger_mode = scope.get_trigger_mode()
print(f" Verified: {actual_trigger_source}, {actual_trigger_level}V, {actual_trigger_slope}, {actual_trigger_mode}\n")
# Configure FastFrame
print("Configuring FastFrame...")
print(" Enabling FastFrame...")
scope.set_fastframe_state(True)
print(" Setting frame count to 1000...")
scope.set_fastframe_count(1000)
# Verify FastFrame settings
ff_state = scope.get_fastframe_state()
ff_count = scope.get_fastframe_count()
print(f" Verified: FastFrame {'enabled' if ff_state else 'disabled'}, {ff_count} frames\n")
# Configure waveform transfer
print("Configuring waveform transfer...")
scope.set_data_encoding('RIBinary')
scope.set_wfmoutpre_encoding('BINary')
scope.set_wfmoutpre_byte_count(1)
scope.set_wfmoutpre_byte_order('MSB')
scope.set_data_source('CH1')
# Verify configuration
actual_encoding = scope.get_data_encoding()
actual_source = scope.get_data_source()
print(f" Verified: {actual_encoding} encoding, source {actual_source}")
print(" Data transfer configured for 8-bit signed binary\n")
print("=== Acquiring FastFrame Records ===\n")
# Wait for FastFrame settings to take effect
print("Waiting for FastFrame configuration to settle...")
time.sleep(0.5)
# Start acquisition - this arms the scope and begins acquiring triggered frames
print("Starting acquisition (arming scope)...")
scope.write("ACQuire:STATE RUN")
print(" Scope is now armed and acquiring triggered events")
# Wait and poll for acquisition to complete
print(f" Waiting for {ff_count} triggers to be acquired...")
print(" (Polling acquisition state...)")
# Poll for up to 10 seconds
for i in range(100):
time.sleep(0.1)
state = scope.query("ACQuire:STATE?")
if i % 10 == 0: # Print every second
print(f" Polling... state: {state.strip()}")
# Check if we've acquired enough frames
if i > 20: # After 2 seconds minimum
break
# Stop acquisition
print(" Stopping acquisition...")
scope.write("ACQuire:STATE STOP")
# Verify we have frames
time.sleep(0.2)
print("✓ Acquisition stopped, scope should now have all 1000 frames\n")
# Clear any leftover data in the receive buffer
print("Clearing receive buffer...")
scope.socket.setblocking(False)
try:
while True:
junk = scope.socket.recv(4096)
if not junk:
break
print(f" Cleared {len(junk)} bytes of junk data")
except:
pass
scope.socket.setblocking(True)
print(" Buffer cleared\n")
# Test transferring a single frame first
print("Testing single frame transfer first...")
try:
scope.set_fastframe_selected(1)
print(" Selected frame 1")
test_curve = scope.transfer_curve()
print(f" ✓ Successfully transferred {len(test_curve)} bytes")
test_waveform = scope.parse_curve_data(test_curve, byte_count=1, signed=True, byte_order='MSB')
print(f" ✓ Parsed {len(test_waveform)} samples")
print(f" First 10 values: {test_waveform[:10]}\n")
except Exception as e:
print(f" ✗ Single frame test failed: {e}")
print(" Cannot proceed with bulk transfer\n")
raise
# Now transfer all 1000 frames
print(f"Transferring {ff_count} frames from scope...")
print("This may take a while...\n")
start_time = time.time()
all_waveforms = []
for frame_num in range(1, ff_count + 1):
# Show progress every 100 frames
if frame_num % 100 == 0 or frame_num == 1:
elapsed = time.time() - start_time
if frame_num > 1:
rate = frame_num / elapsed
eta = (ff_count - frame_num) / rate
print(f" Frame {frame_num}/{ff_count} - Elapsed: {elapsed:.1f}s - Rate: {rate:.1f} frames/s - ETA: {eta:.1f}s")
else:
print(f" Frame {frame_num}/{ff_count}...")
# Select this frame
scope.set_fastframe_selected(frame_num)
# Verify frame selection for first frame
if frame_num == 1:
actual_frame = scope.get_fastframe_selected()
if actual_frame != frame_num:
print(f" Warning: Frame mismatch - requested {frame_num}, got {actual_frame}")
# Transfer curve data directly with longer timeout
old_timeout = scope.socket.gettimeout()
scope.socket.settimeout(30.0)
try:
curve_bytes = scope.transfer_curve()
waveform = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True, byte_order='MSB')
all_waveforms.append(waveform)
finally:
scope.socket.settimeout(old_timeout)
end_time = time.time()
total_time = end_time - start_time
print(f"\n✓ Acquired all {ff_count} frames in {total_time:.2f} seconds")
print(f" Average rate: {ff_count / total_time:.2f} frames/second")
print(f" Average time per frame: {total_time / ff_count * 1000:.2f} ms\n")
# Analyze the acquired data
print("=== Waveform Statistics ===\n")
# Check that all frames have the expected length
frame_lengths = [len(wf) for wf in all_waveforms]
print(f"Frame lengths: {min(frame_lengths)} to {max(frame_lengths)} points")
# Calculate statistics for first frame
first_frame = all_waveforms[0]
print(f"\nFirst frame (frame 1):")
print(f" Samples: {len(first_frame)}")
print(f" Min ADC value: {min(first_frame)}")
print(f" Max ADC value: {max(first_frame)}")
print(f" Average ADC value: {sum(first_frame) / len(first_frame):.2f}")
print(f" First 20 values: {first_frame[:20]}")
# Calculate statistics for middle frame
mid_frame_idx = ff_count // 2
mid_frame = all_waveforms[mid_frame_idx]
print(f"\nMiddle frame (frame {mid_frame_idx + 1}):")
print(f" Samples: {len(mid_frame)}")
print(f" Min ADC value: {min(mid_frame)}")
print(f" Max ADC value: {max(mid_frame)}")
print(f" Average ADC value: {sum(mid_frame) / len(mid_frame):.2f}")
# Calculate statistics for last frame
last_frame = all_waveforms[-1]
print(f"\nLast frame (frame {ff_count}):")
print(f" Samples: {len(last_frame)}")
print(f" Min ADC value: {min(last_frame)}")
print(f" Max ADC value: {max(last_frame)}")
print(f" Average ADC value: {sum(last_frame) / len(last_frame):.2f}")
# Calculate overall statistics
all_values = [val for wf in all_waveforms for val in wf]
print(f"\nOverall statistics (all {ff_count} frames, {len(all_values)} total samples):")
print(f" Min ADC value: {min(all_values)}")
print(f" Max ADC value: {max(all_values)}")
print(f" Average ADC value: {sum(all_values) / len(all_values):.2f}")
print(f" Total data transferred: {len(all_values)} bytes")
print(f" Transfer rate: {len(all_values) / total_time / 1024 / 1024:.2f} MB/s")
# Check for square wave characteristics
print(f"\nSquare wave detection:")
# A square wave should have values clustered around two levels
positive_samples = sum(1 for v in first_frame if v > 0)
negative_samples = sum(1 for v in first_frame if v < 0)
zero_samples = sum(1 for v in first_frame if v == 0)
print(f" Frame 1: {positive_samples} positive, {negative_samples} negative, {zero_samples} zero samples")
# Estimate duty cycle from first frame
if len(first_frame) > 0:
duty_cycle = (positive_samples / len(first_frame)) * 100
print(f" Estimated duty cycle: {duty_cycle:.1f}%")
print("\n=== FastFrame Acquisition Test Completed Successfully! ===")
except Exception as e:
print(f"\nError: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
# Try to disable FastFrame before disconnecting
try:
print("\nDisabling FastFrame...")
scope.set_fastframe_state(False)
except Exception:
pass
scope.disconnect()
print("Disconnected from oscilloscope")
if __name__ == "__main__":
main()
-155
View File
@@ -1,155 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify FastFrame packed transfer hypothesis.
Tests if all FastFrame records come in a single CURVe? transfer,
similar to WFMv3 file format.
"""
import time
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test FastFrame packed transfer"""
scope_ip = "192.168.10.105"
num_frames = 1000
target_record_length = 5000
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=20.0)
try:
scope.connect()
print(f"Connected to {scope.identify()}\n")
# Configure CH1 for 1MHz square wave ±250mV
print("Configuring CH1...")
scope.set_channel_scale(1, 0.1) # 100mV/div
scope.set_channel_offset(1, 0.0)
scope.set_channel_coupling(1, 'DC')
scope.set_channel_termination(1, 50)
# Configure horizontal for 5000 point record length
# At 6.25 GS/s: 5000 points / 6.25e9 = 800ns window = 80ns/div
print("Configuring horizontal for 5000 point record length...")
scope.set_record_length(target_record_length)
scope.set_time_scale(80e-9) # 80ns/div = 800ns window at 6.25 GS/s
# Check actual settings
actual_record_length = scope.get_record_length()
actual_time_scale = scope.get_time_scale()
actual_sample_rate = scope.get_sample_rate()
print(f" Record length: {actual_record_length}")
print(f" Time scale: {actual_time_scale*1e9:.0f} ns/div")
print(f" Sample rate: {actual_sample_rate/1e6:.1f} MS/s")
# Configure trigger
print("Configuring trigger...")
scope.set_trigger_source('CH1')
scope.set_trigger_level(1, 0.1)
scope.set_trigger_slope('RISE')
scope.set_trigger_mode('NORMAL')
# Configure FastFrame
print(f"Configuring FastFrame for {num_frames} frames...")
scope.set_fastframe_state(True)
scope.set_fastframe_count(num_frames)
# Check record length AFTER enabling FastFrame
actual_record_length_ff = scope.get_record_length()
actual_sample_rate_ff = scope.get_sample_rate()
print(f" Record length after FastFrame: {actual_record_length_ff}")
print(f" Sample rate after FastFrame: {actual_sample_rate_ff/1e6:.1f} MS/s")
ff_state = scope.get_fastframe_state()
ff_count = scope.get_fastframe_count()
print(f" FastFrame: {'enabled' if ff_state else 'disabled'}, {ff_count} frames\n")
# Configure waveform transfer
print("Configuring waveform transfer...")
scope.set_data_encoding('RIBinary')
scope.set_wfmoutpre_encoding('BINary')
scope.set_wfmoutpre_byte_count(1)
scope.set_wfmoutpre_byte_order('MSB')
scope.set_data_source('CH1')
# Acquire data
print("\nAcquiring FastFrame data...")
time.sleep(0.5)
scope.write("ACQuire:STATE RUN")
# Wait for triggers (no polling - just wait)
print(" Waiting for triggers...")
time.sleep(3)
scope.write("ACQuire:STATE STOP")
time.sleep(0.5)
print(" Acquisition stopped\n")
# Transfer all frames individually
print(f"\n=== Transferring {num_frames} Frames Individually ===\n")
all_waveforms = []
start_time = time.time()
for frame_num in range(1, num_frames + 1):
# Progress every 100 frames
if frame_num == 1 or frame_num % 100 == 0:
elapsed = time.time() - start_time
if frame_num > 1:
rate = frame_num / elapsed
eta = (num_frames - frame_num) / rate
print(f" Frame {frame_num}/{num_frames} - {elapsed:.1f}s elapsed - {rate:.1f} fps - ETA {eta:.1f}s")
else:
print(f" Frame {frame_num}/{num_frames}...")
# Select frame and wait for it to take effect
scope.set_fastframe_selected(frame_num)
time.sleep(0.01) # Small delay for command processing
# Transfer curve data
curve_bytes = scope.transfer_curve()
waveform = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True, byte_order='MSB')
all_waveforms.append(waveform)
end_time = time.time()
total_time = end_time - start_time
print(f"\n=== Transfer Complete ===")
print(f"Frames transferred: {len(all_waveforms)}")
print(f"Points per frame: {len(all_waveforms[0])}")
print(f"Total time: {total_time:.2f}s")
print(f"Rate: {num_frames / total_time:.1f} frames/s")
print(f"Total data: {sum(len(wf) for wf in all_waveforms)} samples")
print(f"Throughput: {sum(len(wf) for wf in all_waveforms) / total_time / 1e6:.2f} MS/s")
# Show statistics for selected frames
print(f"\n=== Frame Statistics ===")
for frame_idx in [0, num_frames//2, num_frames-1]:
wf = all_waveforms[frame_idx]
print(f"\nFrame {frame_idx + 1}:")
print(f" Samples: {len(wf)}")
print(f" Min: {min(wf)}, Max: {max(wf)}, Avg: {sum(wf)/len(wf):.1f}")
print(f" First 10: {wf[:10]}")
print("\n=== Test Complete ===")
except Exception as e:
print(f"\nError: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
try:
scope.set_fastframe_state(False)
except:
pass
scope.disconnect()
print("\nDisconnected")
if __name__ == "__main__":
main()
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env python3
"""
Simple FastFrame test - minimal setup.
"""
import time
from tektronix_base import TektronixOscilloscopeBase
def main():
scope_ip = "192.168.10.105"
print(f"Connecting to {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=20.0)
try:
scope.connect()
print(f"Connected: {scope.identify()}\n")
# Configure CH1 (same as failing test)
print("Configuring CH1...")
scope.set_channel_scale(1, 0.1)
scope.set_channel_offset(1, 0.0)
scope.set_channel_coupling(1, 'DC')
scope.set_channel_termination(1, 50)
# Configure horizontal (same as failing test)
print("Configuring horizontal...")
scope.set_record_length(5000)
scope.set_time_scale(80e-9)
actual_record_length = scope.get_record_length()
actual_time_scale = scope.get_time_scale()
actual_sample_rate = scope.get_sample_rate()
print(f" Record: {actual_record_length}, Time: {actual_time_scale*1e9:.0f}ns/div, Rate: {actual_sample_rate/1e6:.1f}MS/s")
# Configure trigger (same as failing test)
print("Configuring trigger...")
scope.set_trigger_source('CH1')
scope.set_trigger_level(1, 0.1)
scope.set_trigger_slope('RISE')
scope.set_trigger_mode('NORMAL')
scope.set_trigger_coupling('DC')
# Enable FastFrame with 1000 frames
print("Enabling FastFrame (1000 frames)...")
scope.set_fastframe_state(True)
scope.set_fastframe_count(1000)
ff_state = scope.get_fastframe_state()
ff_count = scope.get_fastframe_count()
print(f" State: {ff_state}, Count: {ff_count}")
# Configure data transfer (same as failing test)
print("Configuring data transfer...")
scope.set_data_encoding('RIBinary')
scope.set_wfmoutpre_encoding('BINary')
scope.set_wfmoutpre_byte_count(1)
scope.set_wfmoutpre_byte_order('MSB')
scope.set_data_source('CH1')
# Acquire
print("Acquiring...")
scope.write("ACQuire:STATE RUN")
time.sleep(2) # Wait for triggers
scope.write("ACQuire:STATE STOP")
time.sleep(0.5)
# Check record length
record_len = scope.get_record_length()
print(f"Record length: {record_len}")
# Test: Send single CURVe? and read ALL frames
print(f"\nTesting: Send one CURVe? and read all {ff_count} frames...")
scope.set_fastframe_selected(1) # Start from frame 1
scope.write("CURVe?")
all_waveforms = []
start_time = time.time()
for frame_num in range(1, ff_count + 1):
if frame_num == 1 or frame_num % 100 == 0:
elapsed = time.time() - start_time
if frame_num > 1:
rate = frame_num / elapsed
print(f" Frame {frame_num}/{ff_count} - {rate:.1f} fps")
else:
print(f" Frame {frame_num}/{ff_count}...")
# Read one frame using read_raw (no new CURVe? command)
curve_bytes = scope.read_raw()
waveform = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True)
all_waveforms.append(waveform)
end_time = time.time()
total_time = end_time - start_time
print(f"\nTransferred {len(all_waveforms)} frames in {total_time:.2f}s")
print(f"Rate: {ff_count / total_time:.1f} frames/s")
print(f"Points per frame: {len(all_waveforms[0])}")
print(f"First frame: min={min(all_waveforms[0])}, max={max(all_waveforms[0])}")
print(f"Last frame: min={min(all_waveforms[-1])}, max={max(all_waveforms[-1])}")
except Exception as e:
print(f"\nError: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
try:
scope.set_fastframe_state(False)
print("FastFrame disabled")
except:
pass
scope.disconnect()
print("Disconnected")
if __name__ == "__main__":
main()
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""
Test script for horizontal mode control functionality.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test horizontal mode functions on oscilloscope at 192.168.10.105"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip)
try:
# Connect to scope
scope.connect()
print(f"✓ Connected to {scope.identify()}\n")
# Get current horizontal settings
print("Getting current horizontal settings...")
current_record_length = scope.get_record_length()
current_sample_rate = scope.get_sample_rate()
current_time_scale = scope.get_time_scale()
print(f"✓ Current record length: {current_record_length} samples")
print(f"✓ Current sample rate: {current_sample_rate} S/s")
print(f"✓ Current time scale: {current_time_scale} s/div\n")
# Test setting record length
print("Testing record length changes:")
test_lengths = [1000, 10000, 100000]
for length in test_lengths:
print(f" Setting record length to {length}...")
scope.set_record_length(length)
actual_length = scope.get_record_length()
print(f" ✓ Actual record length: {actual_length}")
# Test setting sample rate
print("\nTesting sample rate changes:")
test_rates = [1e6, 10e6, 100e6] # 1 MS/s, 10 MS/s, 100 MS/s
for rate in test_rates:
print(f" Setting sample rate to {rate:.0f} S/s...")
scope.set_sample_rate(rate)
actual_rate = scope.get_sample_rate()
print(f" ✓ Actual sample rate: {actual_rate:.0f} S/s")
# Test setting time scale
print("\nTesting time scale changes:")
test_scales = [1e-6, 10e-6, 100e-6, 1e-3] # 1 µs/div, 10 µs/div, 100 µs/div, 1 ms/div
for scale in test_scales:
print(f" Setting time scale to {scale:.6f} s/div...")
scope.set_time_scale(scale)
actual_scale = scope.get_time_scale()
print(f" ✓ Actual time scale: {actual_scale:.6f} s/div")
# Restore original settings
print(f"\nRestoring original settings...")
scope.set_record_length(current_record_length)
scope.set_sample_rate(current_sample_rate)
scope.set_time_scale(current_time_scale)
print(f"✓ Restored record length: {scope.get_record_length()}")
print(f"✓ Restored sample rate: {scope.get_sample_rate()}")
print(f"✓ Restored time scale: {scope.get_time_scale()}")
# Test invalid inputs
print("\nTesting invalid record length (should raise ValueError)...")
try:
scope.set_record_length(-1)
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
print("\nTesting invalid sample rate (should raise ValueError)...")
try:
scope.set_sample_rate(0)
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
print("\nTesting invalid time scale (should raise ValueError)...")
try:
scope.set_time_scale(-1.0)
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
except Exception as e:
print(f"✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("\n✓ Disconnected from oscilloscope")
if __name__ == "__main__":
main()
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env python3
"""
Simple curve transfer test - no FastFrame.
"""
import time
from tektronix_base import TektronixOscilloscopeBase
def main():
scope_ip = "192.168.10.105"
print(f"Connecting to {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=20.0)
try:
scope.connect()
print(f"Connected: {scope.identify()}\n")
# Make sure FastFrame is OFF
print("Disabling FastFrame...")
scope.set_fastframe_state(False)
# Configure simple acquisition
print("Configuring acquisition...")
scope.set_data_encoding('RIBinary')
scope.set_data_source('CH1')
# Single acquisition
print("Running single acquisition...")
scope.write("ACQuire:STATE RUN")
time.sleep(0.5)
scope.write("ACQuire:STATE STOP")
time.sleep(0.2)
# Transfer curve
print("Transferring curve data...")
curve_bytes = scope.transfer_curve()
print(f"Received {len(curve_bytes)} bytes")
# Parse
waveform = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True)
print(f"Parsed {len(waveform)} samples")
print(f"Min: {min(waveform)}, Max: {max(waveform)}")
print(f"First 10: {waveform[:10]}")
print("\nSuccess!")
except Exception as e:
print(f"\nError: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("Disconnected")
if __name__ == "__main__":
main()
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""
Simple test to debug query timeout issues.
"""
from tektronix_base import TektronixOscilloscopeBase
import time
def main():
"""Test simple queries with the oscilloscope"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=10.0)
try:
# Connect
scope.connect()
print("✓ Connected")
# Wait a moment to ensure connection is stable
time.sleep(0.5)
# Try a simple query
print("\nSending *IDN? query...")
idn = scope.query("*IDN?")
print(f"✓ Response: {idn}")
# Try acquisition mode query
print("\nSending ACQuire:MODe? query...")
mode = scope.query("ACQuire:MODe?")
print(f"✓ Current mode: {mode}")
# Set mode to sample using short form
print("\nSetting mode to SAM (short form)...")
scope.set_acquire_mode("SAM")
# Verify
mode = scope.get_acquire_mode()
print(f"✓ Mode is now: {mode}")
except Exception as e:
print(f"✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("\n✓ Disconnected")
if __name__ == "__main__":
main()
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""
Simple test to verify basic waveform acquisition works.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test single waveform acquisition from CH1"""
scope_ip = "192.168.10.105"
print(f"Connecting to {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=15.0)
try:
scope.connect()
print(f"✓ Connected to {scope.identify()}\n")
# Check current settings
print("Current settings:")
print(f" Data encoding: {scope.get_data_encoding()}")
print(f" Data source: {scope.get_data_source()}")
print(f" Record length: {scope.get_record_length()}")
print(f" FastFrame state: {scope.get_fastframe_state()}\n")
# Ensure FastFrame is off
if scope.get_fastframe_state():
print("Disabling FastFrame...")
scope.set_fastframe_state(False)
# Set data source to CH1
print("Setting data source to CH1...")
scope.set_data_source('CH1')
# Try to acquire a single waveform
print("\nAttempting to acquire waveform from CH1...")
print("(This will timeout if the scope isn't responding properly)\n")
waveform = scope.acquire_waveform('CH1')
print(f"✓ SUCCESS! Acquired {len(waveform)} samples")
print(f" First 10 values: {waveform[:10]}")
print(f" Min: {min(waveform)}, Max: {max(waveform)}")
print(f" Average: {sum(waveform)/len(waveform):.2f}")
except Exception as e:
print(f"\n✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("\n✓ Disconnected")
if __name__ == "__main__":
main()
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
Test script for transfer_fastframe() method.
Tests bulk FastFrame transfer with 1MHz square wave on CH1.
Signal: 1MHz square wave, +125mV to -125mV
"""
import time
from tektronix_base import TektronixOscilloscopeBase
def main():
scope_ip = "192.168.10.105"
num_frames = 1000
print(f"Connecting to {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=30.0)
try:
scope.connect()
print(f"Connected: {scope.identify()}\n")
# Configure CH1
print("Configuring CH1...")
scope.set_channel_scale(1, 0.1) # 100mV/div
scope.set_channel_offset(1, 0.0)
scope.set_channel_coupling(1, 'DC')
scope.set_channel_termination(1, 50)
# Configure horizontal
print("Configuring horizontal...")
scope.set_record_length(5000)
scope.set_time_scale(80e-9) # 80ns/div
# Configure trigger
print("Configuring trigger...")
scope.set_trigger_source('CH1')
scope.set_trigger_level(1, 0.05) # 50mV
scope.set_trigger_slope('RISE')
scope.set_trigger_mode('NORMAL')
# Configure FastFrame
print(f"Configuring FastFrame ({num_frames} frames)...")
scope.set_fastframe_state(True)
scope.set_fastframe_count(num_frames)
# Verify settings
ff_count = scope.get_fastframe_count()
record_len = scope.get_record_length()
print(f" Frames: {ff_count}, Record length: {record_len}")
# Configure data transfer
print("Configuring data transfer...")
scope.set_data_encoding('RIBinary')
scope.set_data_source('CH1')
# Acquire
print("\nAcquiring FastFrame data...")
scope.write("ACQuire:STATE RUN")
time.sleep(2) # Wait for triggers
scope.write("ACQuire:STATE STOP")
time.sleep(0.5)
print(" Acquisition complete")
# Transfer all frames using the new method
print(f"\nTransferring {ff_count} frames using transfer_fastframe()...")
start_time = time.time()
waveforms = scope.transfer_fastframe(parse=True, byte_count=1, signed=True)
end_time = time.time()
total_time = end_time - start_time
# Results
print(f"\n=== Transfer Complete ===")
print(f"Frames received: {len(waveforms)}")
print(f"Points per frame: {len(waveforms[0])}")
print(f"Total samples: {sum(len(wf) for wf in waveforms)}")
print(f"Transfer time: {total_time:.3f}s")
print(f"Frame rate: {len(waveforms) / total_time:.1f} frames/s")
print(f"Sample rate: {sum(len(wf) for wf in waveforms) / total_time / 1e6:.2f} MS/s")
# Waveform statistics
print(f"\n=== Waveform Statistics ===")
print(f"Frame 1: min={min(waveforms[0])}, max={max(waveforms[0])}, avg={sum(waveforms[0])/len(waveforms[0]):.1f}")
print(f"Frame {len(waveforms)//2}: min={min(waveforms[len(waveforms)//2])}, max={max(waveforms[len(waveforms)//2])}")
print(f"Frame {len(waveforms)}: min={min(waveforms[-1])}, max={max(waveforms[-1])}")
# Check for square wave
first_frame = waveforms[0]
positive = sum(1 for v in first_frame if v > 0)
negative = sum(1 for v in first_frame if v < 0)
print(f"\nSquare wave check (frame 1): {positive} positive, {negative} negative samples")
print(f"Duty cycle estimate: {positive / len(first_frame) * 100:.1f}%")
print("\n=== Success! ===")
except Exception as e:
print(f"\nError: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
try:
scope.set_fastframe_state(False)
print("\nFastFrame disabled")
except:
pass
scope.disconnect()
print("Disconnected")
if __name__ == "__main__":
main()
-162
View File
@@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""
Test script for trigger control functionality.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test trigger functions on oscilloscope at 192.168.10.105"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip)
try:
# Connect to scope
scope.connect()
print(f"✓ Connected to {scope.identify()}\n")
# Get current trigger settings
print("Getting current trigger settings...")
current_coupling = scope.get_trigger_coupling()
current_slope = scope.get_trigger_slope()
current_source = scope.get_trigger_source()
current_mode = scope.get_trigger_mode()
print(f"✓ Current trigger coupling: {current_coupling}")
print(f"✓ Current trigger slope: {current_slope}")
print(f"✓ Current trigger source: {current_source}")
print(f"✓ Current trigger mode: {current_mode}")
# Get trigger level for current source
# Extract channel number from source
if current_source.startswith('CH'):
channel = int(current_source[2])
current_level = scope.get_trigger_level(channel)
print(f"✓ Current trigger level for {current_source}: {current_level} V\n")
else:
current_level = 0.0
channel = 1
print(f" (Source is {current_source}, not a channel)\n")
# Test trigger coupling
print("Testing trigger coupling modes:")
test_couplings = ['DC', 'HFRej', 'LFRej', 'NOISErej']
for coupling in test_couplings:
print(f" Setting coupling to {coupling}...")
scope.set_trigger_coupling(coupling)
actual_coupling = scope.get_trigger_coupling()
print(f" ✓ Actual coupling: {actual_coupling}")
# Test trigger slope
print("\nTesting trigger slope modes:")
test_slopes = ['RISe', 'FALL', 'EITher']
for slope in test_slopes:
print(f" Setting slope to {slope}...")
scope.set_trigger_slope(slope)
actual_slope = scope.get_trigger_slope()
print(f" ✓ Actual slope: {actual_slope}")
# Test trigger source
print("\nTesting trigger source selection:")
test_sources = ['CH1', 'CH2', 'CH3', 'CH4']
for source in test_sources:
print(f" Setting source to {source}...")
scope.set_trigger_source(source)
actual_source = scope.get_trigger_source()
print(f" ✓ Actual source: {actual_source}")
# Test trigger source with integer
print("\nTesting trigger source with integer (2)...")
scope.set_trigger_source(2)
actual_source = scope.get_trigger_source()
print(f"✓ Actual source: {actual_source}")
# Test trigger level
print("\nTesting trigger level settings:")
test_levels = [0.0, 0.5, 1.0, -0.5, 2.5]
for level in test_levels:
print(f" Setting CH1 trigger level to {level} V...")
scope.set_trigger_level(1, level)
actual_level = scope.get_trigger_level(1)
print(f" ✓ Actual level: {actual_level} V")
# Test trigger level with channel string
print("\nTesting trigger level with channel string ('CH2')...")
scope.set_trigger_level('CH2', 1.5)
actual_level = scope.get_trigger_level('CH2')
print(f"✓ Actual level: {actual_level} V")
# Test trigger mode
print("\nTesting trigger modes:")
test_modes = ['AUTO', 'NORMal']
for mode in test_modes:
print(f" Setting trigger mode to {mode}...")
scope.set_trigger_mode(mode)
actual_mode = scope.get_trigger_mode()
print(f" ✓ Actual mode: {actual_mode}")
# Restore original settings
print(f"\nRestoring original trigger settings...")
scope.set_trigger_coupling(current_coupling)
scope.set_trigger_slope(current_slope)
scope.set_trigger_source(current_source)
scope.set_trigger_mode(current_mode)
if current_source.startswith('CH'):
scope.set_trigger_level(channel, current_level)
print(f"✓ Restored trigger coupling: {scope.get_trigger_coupling()}")
print(f"✓ Restored trigger slope: {scope.get_trigger_slope()}")
print(f"✓ Restored trigger source: {scope.get_trigger_source()}")
print(f"✓ Restored trigger mode: {scope.get_trigger_mode()}")
# Test invalid inputs
print("\nTesting invalid coupling (should raise ValueError)...")
try:
scope.set_trigger_coupling("INVALID")
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
print("\nTesting invalid slope (should raise ValueError)...")
try:
scope.set_trigger_slope("INVALID")
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
print("\nTesting invalid source (should raise ValueError)...")
try:
scope.set_trigger_source("CH5")
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
print("\nTesting invalid channel for trigger level (should raise ValueError)...")
try:
scope.set_trigger_level(5, 0.0)
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
print("\nTesting invalid trigger mode (should raise ValueError)...")
try:
scope.set_trigger_mode("INVALID")
print("✗ ERROR: Should have raised ValueError!")
except ValueError as e:
print(f"✓ Correctly raised ValueError: {e}")
except Exception as e:
print(f"✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("\n✓ Disconnected from oscilloscope")
if __name__ == "__main__":
main()
-273
View File
@@ -1,273 +0,0 @@
#!/usr/bin/env python3
"""
Test script for waveform transfer functionality.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Test waveform transfer functions on oscilloscope at 192.168.10.105"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip)
try:
# Connect to scope
scope.connect()
print(f"Connected to {scope.identify()}\n")
# Save current settings
print("Saving current waveform transfer settings...")
current_data_encoding = scope.get_data_encoding()
current_data_source = scope.get_data_source()
current_wfmoutpre_encoding = scope.get_wfmoutpre_encoding()
current_byte_count = scope.get_wfmoutpre_byte_count()
current_byte_order = scope.get_wfmoutpre_byte_order()
print(f" Current data encoding: {current_data_encoding}")
print(f" Current data source: {current_data_source}")
print(f" Current WFMOutpre encoding: {current_wfmoutpre_encoding}")
print(f" Current byte count: {current_byte_count}")
print(f" Current byte order: {current_byte_order}\n")
# Test setting data encoding
print("Testing data encoding settings:")
test_encodings = ['ASCIi', 'RIBinary', 'RPBinary']
for encoding in test_encodings:
print(f" Setting data encoding to {encoding}...")
scope.set_data_encoding(encoding)
actual = scope.get_data_encoding()
print(f" Actual encoding: {actual}")
# Test setting data source
print("\nTesting data source selection:")
for source in ['CH1', 'CH2', 'CH3', 'CH4']:
print(f" Setting data source to {source}...")
scope.set_data_source(source)
actual = scope.get_data_source()
print(f" Actual source: {actual}")
# Test with integer source
print("\nTesting data source with integer (2)...")
scope.set_data_source(2)
actual = scope.get_data_source()
print(f" Actual source: {actual}")
# Test waveform preamble encoding
print("\nTesting waveform preamble encoding:")
for encoding in ['BINary', 'ASCii']:
print(f" Setting WFMOutpre encoding to {encoding}...")
scope.set_wfmoutpre_encoding(encoding)
actual = scope.get_wfmoutpre_encoding()
print(f" Actual encoding: {actual}")
# Test byte count
print("\nTesting byte count settings:")
for byte_count in [1, 2]:
print(f" Setting byte count to {byte_count}...")
scope.set_wfmoutpre_byte_count(byte_count)
actual = scope.get_wfmoutpre_byte_count()
print(f" Actual byte count: {actual}")
# Test byte order
print("\nTesting byte order settings:")
for byte_order in ['MSB', 'LSB']:
print(f" Setting byte order to {byte_order}...")
scope.set_wfmoutpre_byte_order(byte_order)
actual = scope.get_wfmoutpre_byte_order()
print(f" Actual byte order: {actual}")
# Query complete waveform preamble
print("\nQuerying complete waveform preamble...")
preamble = scope.query_wfmoutpre()
print(f" Preamble (first 100 chars): {preamble[:100]}...")
# Set up for binary waveform acquisition
print("\nConfiguring for binary waveform transfer:")
print(" Setting data encoding to RIBinary...")
scope.set_data_encoding('RIBinary')
print(" Setting WFMOutpre encoding to BINary...")
scope.set_wfmoutpre_encoding('BINary')
print(" Setting byte count to 1...")
scope.set_wfmoutpre_byte_count(1)
print(" Setting byte order to MSB...")
scope.set_wfmoutpre_byte_order('MSB')
print(" Setting data source to CH1...")
scope.set_data_source('CH1')
print(" Configuration complete")
# Transfer curve data
print("\nTransferring curve data from CH1...")
curve_data = scope.transfer_curve()
print(f" Received {len(curve_data)} bytes of curve data")
print(f" First 10 bytes (raw): {list(curve_data[:10])}")
# Parse the curve data
print("\nParsing curve data...")
values = scope.parse_curve_data(curve_data, byte_count=1, signed=True, byte_order='MSB')
print(f" Parsed {len(values)} samples")
print(f" First 10 values: {values[:10]}")
print(f" Min value: {min(values)}")
print(f" Max value: {max(values)}")
print(f" Average value: {sum(values) / len(values):.2f}")
# Transfer complete waveform (preamble + curve)
print("\nTransferring complete waveform (WAVFrm?)...")
preamble_str, curve_bytes = scope.transfer_waveform()
print(f" Received preamble: {preamble_str[:100]}...")
print(f" Received {len(curve_bytes)} bytes of curve data")
# Parse this curve data too
waveform_values = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True, byte_order='MSB')
print(f" Parsed {len(waveform_values)} samples from complete waveform")
# Test high-level acquire_waveform method
print("\nTesting high-level acquire_waveform method:")
print(" Acquiring waveform from CH1...")
waveform = scope.acquire_waveform('CH1')
print(f" Acquired {len(waveform)} samples")
print(f" First 10 values: {waveform[:10]}")
print(f" Min: {min(waveform)}, Max: {max(waveform)}, Avg: {sum(waveform)/len(waveform):.2f}")
print("\n Acquiring waveform from CH2 (using integer)...")
try:
waveform2 = scope.acquire_waveform(2)
print(f" Acquired {len(waveform2)} samples from CH2")
print(f" First 10 values: {waveform2[:10]}")
except Exception as e:
print(f" Could not acquire from CH2 (channel may not be active): {e}")
# Test FastFrame support
print("\nTesting FastFrame frame selection:")
# Check if FastFrame is currently enabled
try:
ff_state = scope.get_fastframe_state()
ff_count = scope.get_fastframe_count()
print(f" Current FastFrame state: {ff_state} ({'active' if ff_state else 'off'})")
print(f" Current frame count: {ff_count}")
except Exception as e:
print(f" Could not query FastFrame state (skipping FastFrame tests): {e}")
ff_state = None
if ff_state is not None and ff_state:
# FastFrame is active, test frame selection
print(" FastFrame is active, testing frame selection...")
current_frame = scope.get_fastframe_selected()
print(f" Current selected frame: {current_frame}")
# Try selecting different frames
for frame in [1, min(5, ff_count), ff_count]:
print(f" Selecting frame {frame}...")
scope.set_fastframe_selected(frame)
actual = scope.get_fastframe_selected()
print(f" Selected frame: {actual}")
# Acquire waveform from this frame
print(f" Acquiring waveform from frame {frame}...")
frame_waveform = scope.acquire_waveform('CH1', frame_number=frame)
print(f" Acquired {len(frame_waveform)} samples")
print(f" First 5 values: {frame_waveform[:5]}")
# Restore original frame
scope.set_fastframe_selected(current_frame)
elif ff_state is not None:
print(" FastFrame is not active, enabling it temporarily...")
scope.set_fastframe_state(True)
scope.set_fastframe_count(10)
print(" FastFrame enabled with 10 frames")
# Test frame selection
for frame in [1, 5, 10]:
print(f" Selecting frame {frame}...")
scope.set_fastframe_selected(frame)
actual = scope.get_fastframe_selected()
print(f" Selected frame: {actual}")
# Restore FastFrame state
scope.set_fastframe_state(False)
print(" FastFrame disabled (restored)")
# Restore original settings
print("\nRestoring original waveform transfer settings...")
try:
scope.set_data_encoding(current_data_encoding)
scope.set_data_source(current_data_source)
scope.set_wfmoutpre_encoding(current_wfmoutpre_encoding)
scope.set_wfmoutpre_byte_count(current_byte_count)
scope.set_wfmoutpre_byte_order(current_byte_order)
print(" Settings restored")
except Exception as e:
print(f" Could not restore settings (connection may be in bad state): {e}")
# Test error handling
print("\n=== Testing Error Handling ===\n")
# Skip error handling tests if connection is already bad
try:
# Quick connectivity check
scope.query("*OPC?")
except Exception:
print("Connection appears to be in bad state, skipping error handling tests\n")
print("=== Waveform transfer tests completed (with some skipped due to connection issues) ===")
return
print("Testing invalid data encoding (should raise ValueError)...")
try:
scope.set_data_encoding('INVALID')
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid data source (should raise ValueError)...")
try:
scope.set_data_source('CH5')
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid byte count (should raise ValueError)...")
try:
scope.set_wfmoutpre_byte_count(3)
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid byte order (should raise ValueError)...")
try:
scope.set_wfmoutpre_byte_order('INVALID')
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid FastFrame frame number (should raise ValueError)...")
try:
scope.set_fastframe_selected(-1)
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\nTesting invalid channel in acquire_waveform (should raise ValueError)...")
try:
scope.acquire_waveform(5)
print(" ERROR: Should have raised ValueError!")
except ValueError as e:
print(f" Correctly raised ValueError: {e}")
print("\n=== All waveform transfer tests completed successfully! ===")
except Exception as e:
print(f"\nError: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("\nDisconnected from oscilloscope")
if __name__ == "__main__":
main()
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env python3
"""
Simple focused test for waveform transfer functionality.
"""
from tektronix_base import TektronixOscilloscopeBase
def main():
"""Simple test of core waveform transfer on CH1"""
scope_ip = "192.168.10.105"
print(f"Connecting to oscilloscope at {scope_ip}...")
scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=10.0)
try:
# Connect to scope
scope.connect()
print(f"Connected to {scope.identify()}\n")
# Test 1: Configure and transfer using low-level methods
print("=== Test 1: Low-level waveform transfer ===")
print("Configuring data transfer settings...")
scope.set_data_encoding('RIBinary')
scope.set_wfmoutpre_encoding('BINary')
scope.set_wfmoutpre_byte_count(1)
scope.set_wfmoutpre_byte_order('MSB')
scope.set_data_source('CH1')
print("Transferring curve data from CH1...")
curve_data = scope.transfer_curve()
print(f" Received {len(curve_data)} bytes")
print("Parsing curve data...")
values = scope.parse_curve_data(curve_data, byte_count=1, signed=True, byte_order='MSB')
print(f" Parsed {len(values)} samples")
print(f" First 10 values: {values[:10]}")
print(f" Min: {min(values)}, Max: {max(values)}, Avg: {sum(values)/len(values):.2f}")
# Test 2: Use high-level acquire_waveform method
print("\n=== Test 2: High-level waveform acquisition ===")
print("Acquiring waveform from CH1...")
waveform = scope.acquire_waveform('CH1')
print(f" Acquired {len(waveform)} samples")
print(f" First 10 values: {waveform[:10]}")
print(f" Min: {min(waveform)}, Max: {max(waveform)}, Avg: {sum(waveform)/len(waveform):.2f}")
# Test 3: Query waveform preamble
print("\n=== Test 3: Waveform preamble ===")
preamble = scope.query_wfmoutpre()
print(f" Preamble: {preamble[:150]}...")
# Test 4: Transfer complete waveform (preamble + curve)
print("\n=== Test 4: Complete waveform transfer ===")
print("Transferring complete waveform...")
preamble_str, curve_bytes = scope.transfer_waveform()
print(f" Preamble length: {len(preamble_str)} chars")
print(f" Curve data: {len(curve_bytes)} bytes")
wf_values = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True, byte_order='MSB')
print(f" Parsed {len(wf_values)} samples")
print("\n=== All tests completed successfully! ===")
except Exception as e:
print(f"\nError: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
finally:
if scope.is_connected:
scope.disconnect()
print("\nDisconnected from oscilloscope")
if __name__ == "__main__":
main()
-315
View File
@@ -1,315 +0,0 @@
# Coherent HOPS Laser I2C Protocol Documentation
This document describes the I2C communication protocol used to control Coherent HOPS laser systems, extracted from the CohrHopsDemo v2.0.7 codebase.
## Hardware Overview
### FTDI Interface
- **Chip**: FT2232C (dual-channel USB)
- **Protocol**: I2C via MPSSE (Multi-Protocol Synchronous Serial Engine)
- **Library**: CohrFTCI2C.dll (Windows), use libftdi/libmpsse on Linux
### I2C Configuration
| Parameter | Value/Range |
|-----------|-------------|
| Clock Divisor | 0 - 65535 |
| Modes | STANDARD, FAST |
| Control Bytes | 1 - 255 |
| Data Bytes | 1 - 65535 |
### I2C Slave
- **Device**: NXP microcontroller
- **Role**: Intermediary between FTDI and laser hardware
---
## I2C Library Functions
These are the low-level FTDI I2C functions (from CohrFTCI2C.dll):
| Function | Purpose |
|----------|---------|
| `I2C_GetNumDevices` | Enumerate connected I2C devices |
| `I2C_GetDeviceNameLocID` | Get device location identifier |
| `I2C_GetDeviceNameSerialNumber` | Get device serial number |
| `I2C_Open` | Open I2C device |
| `I2C_OpenEx` | Extended open with options |
| `I2C_OpenSerialNumber` | Open device by serial number |
| `I2C_InitDevice` | Initialize MPSSE interface |
| `I2C_SetMode` | Set STANDARD or FAST mode |
| `I2C_GetClock` | Get current clock divisor |
| `I2C_SetClock` | Set clock divisor |
| `I2C_SetLoopback` | Enable/disable loopback testing |
| `I2C_Write` | Write control + data bytes |
| `I2C_Read` | Read data bytes |
| `I2C_ReadAlt` | Alternative read function |
| `I2C_Close` | Close I2C device |
| `I2C_GetErrorCodeString` | Get error descriptions |
---
## NXP Slave Operations
The NXP microcontroller provides these I2C operations:
| Method | Purpose |
|--------|---------|
| `NXP::Write` | Write data to I2C slave |
| `NXP::Read` | Read data from I2C slave |
| `NXP::WriteRegister` | Write to internal registers |
| `NXP::ReadRegister` | Read from internal registers |
| `NXP::WriteGPIO` | Control GPIO outputs |
| `NXP::ReadGPIO` | Read GPIO inputs |
---
## I2C Transaction Format
### Write Operation
```
1. WriteControlBuffer: I2C slave address + W bit (0)
2. WriteDataBuffer: Register address + data
- BYTE mode: Single byte writes
- PAGE mode: Multi-byte writes
```
### Read Operation
```
1. WriteControlBuffer: I2C slave address + R bit (1)
2. ReadDataBuffer: Receive response
- BYTE mode: Single byte reads
- BLOCK mode: Multi-byte reads
```
---
## High-Level Command Interface
Commands are sent via `CohrHOPS_SendCommand()` using the format `?COMMAND` for queries.
### System Information Commands
| Command | Purpose | Example Response |
|---------|---------|------------------|
| `?HID` | Query Hardware ID | Device identifier |
| `?HTYPE` | Query Head Type | Head variant |
| `?HBDREV` | Query Head Board Revision | PCB revision |
| `?HEADDIO` | Query Head Digital I/O | DIO configuration |
| `?LASERMODEL` | Query Laser Model | G532, Tina, Mini00, MiniX |
| `?POWERUNITS` | Query Power Units | mW, W, etc. |
| `?WAVELENGTH` | Query Wavelength | 532nm, etc. |
### Temperature Monitoring
| Command | Purpose |
|---------|---------|
| `?TMAIN` | Main Heatsink Temperature |
| `?TBRF` | BRF (Birefringent Filter) Temperature |
| `?TSHG` | SHG (Second Harmonic Generator) Temperature |
| `?TTHG` | THG (Third Harmonic Generator) Temperature |
| `?TETA` | ETA Temperature |
### Temperature Control (Setpoints)
| Command | Purpose |
|---------|---------|
| `?TMAINCMD` | Get/Set Main Temperature Setpoint |
| `?TBRFCMD` | Get/Set BRF Temperature Setpoint |
| `?TSHGCMD` | Get/Set SHG Temperature Setpoint |
| `?TTHGCMD` | Get/Set THG Temperature Setpoint |
| `?TETACMD` | Get/Set ETA Temperature Setpoint |
### Temperature Data
| Command | Purpose |
|---------|---------|
| `?MAIND` | Main Temperature Data |
| `?BRFD` | BRF Temperature Data |
| `?SHGD` | SHG Temperature Data |
| `?THGD` | THG Temperature Data |
| `?ETAD` | ETA Temperature Data |
### Power Control
| Command | Purpose |
|---------|---------|
| `?PCMD` | Get/Set Power Command |
| `?PMEM` | Query Power Memory (stored settings) |
| `?PLIM` | Query Power Limits |
### Current Control
| Command | Purpose |
|---------|---------|
| `?CCMD` | Get/Set Current Command |
| `?CLIM` | Query Current Limits |
| `?CMODE` | Get/Set Control Mode |
| `?CMODECMD` | Get/Set Control Mode Command |
### Digital I/O
| Command | Purpose |
|---------|---------|
| `?PSDIO` | Power Supply Digital I/O |
| `?PSGLUEIN` | Power Supply Glue Logic Input |
| `?PSGLUEOUT` | Power Supply Glue Logic Output |
### Monitoring & Status
| Command | Purpose |
|---------|---------|
| `?ANA` | Query Analog Values |
| `?ANACMD` | Get/Set Analog Command |
| `?KSW` | Key Switch Status |
| `?KSWCMD` | Get/Set Key Switch Command |
| `?FAN` | Fan Status/Control |
| `?INT` | Interlock Status |
| `?REM` | Remote Control Status |
| `?EEH` | EEPROM Header |
### Configuration Registers
| Command | Purpose |
|---------|---------|
| `?CFG0` | Configuration Register 0 |
| `?CFG1` | Configuration Register 1 |
| `?CFG2` | Configuration Register 2 |
| `?CFG3` | Configuration Register 3 |
---
## Supported Laser Models
| Model | Description |
|-------|-------------|
| G532 | 532nm Green Laser |
| Tina | Proprietary Model |
| Mini00 | Compact Variant |
| MiniX | Extended Mini Variant |
| CommonLaser | Base Implementation |
| DummyLaser | Test/Simulation |
---
## Linux Implementation Guide
### Required Libraries
For Linux implementation, use one of:
- **libftdi** + **libmpsse** - Direct FTDI MPSSE control
- **pylibftdi** - Python bindings for libftdi
- Standard Linux I2C (`/dev/i2c-*`) if FTDI exposes as I2C adapter
### Installation (Debian/Ubuntu)
```bash
sudo apt install libftdi-dev libmpsse-dev
```
### Basic Implementation Steps
1. **Initialize FTDI Device**
```c
// Find and open FT2232C device
ftdi_init(&ftdi);
ftdi_usb_open(&ftdi, 0x0403, 0x6010); // FTDI VID/PID
```
2. **Configure MPSSE for I2C**
```c
// Enable MPSSE mode
ftdi_set_bitmode(&ftdi, 0, BITMODE_MPSSE);
// Set I2C clock speed
// Clock = 60MHz / ((1 + divisor) * 2)
```
3. **Send I2C Commands**
```c
// Write command to laser
i2c_write(slave_addr, "?HID", 4);
// Read response
i2c_read(slave_addr, buffer, sizeof(buffer));
```
### Example: Query Laser Model
```c
#include <ftdi.h>
#include <mpsse.h>
int main() {
struct mpsse_context *i2c;
char response[256];
// Open I2C at 100kHz
i2c = MPSSE(I2C, ONE_HUNDRED_KHZ, MSB);
if (i2c && i2c->open) {
// Send query command
Start(i2c);
Write(i2c, "?LASERMODEL", 11);
Stop(i2c);
// Read response
Start(i2c);
char *data = Read(i2c, 256);
Stop(i2c);
printf("Laser Model: %s\n", data);
free(data);
}
Close(i2c);
return 0;
}
```
---
## Error Handling
### Common Errors
| Error | Description |
|-------|-------------|
| Timeout after control byte | No ACK received after sending slave address |
| Timeout after data byte | No ACK received after sending data |
| MPSSE sync failure | Failed to synchronize FTDI MPSSE interface |
### Recovery
1. Reset MPSSE interface
2. Re-initialize I2C
3. Check physical connections
4. Verify I2C slave address
---
## Protocol Notes
- Commands use ASCII text format
- Query commands start with `?`
- Set commands likely use `=` followed by value
- Responses are ASCII strings
- Temperature values likely in degrees Celsius
- Power values use units from `?POWERUNITS` response
---
## Source Files Reference
| File | Purpose |
|------|---------|
| `CohrHOPS.dll` | Main laser control library |
| `CohrFTCI2C.dll` | FTDI I2C bridge library |
| `main.c` | Demo application |
---
## Additional Resources
- FTDI MPSSE Documentation: https://ftdichip.com/software-examples/mpsse-projects/
- libmpsse: https://github.com/devttys0/libmpsse
- Linux I2C: https://www.kernel.org/doc/html/latest/i2c/
-283
View File
@@ -1,283 +0,0 @@
# PyPewPewHOPS - Coherent HOPS Laser Control Library
Python library for controlling Coherent HOPS laser systems via I2C protocol through an FTDI FT2232C USB interface.
## Features
- Complete implementation of all documented I2C commands
- Support for system information queries
- Temperature monitoring and control for all sensors
- Power and current control
- Digital I/O operations
- Configuration register access
- Context manager support for safe resource handling
- Dummy laser simulator for development without hardware
- Comprehensive error handling
## Installation
### Requirements
- Python 3.7+
- FTDI FT2232C USB device
- libftdi library (for Linux)
### Install Dependencies
```bash
pip install -r requirements.txt
```
### Linux Setup
On Linux, you may need to install libftdi:
```bash
# Debian/Ubuntu
sudo apt install libftdi-dev
# Fedora
sudo dnf install libftdi-devel
```
You may also need to add your user to the appropriate group:
```bash
sudo usermod -a -G dialout $USER
sudo usermod -a -G plugdev $USER
```
Then log out and log back in for the changes to take effect.
## Quick Start
### Using the Simulator (No Hardware)
```python
from coherent_hops_laser import DummyLaser
with DummyLaser() as laser:
# Query system information
info = laser.get_system_info()
print(f"Model: {info.laser_model}")
print(f"Wavelength: {info.wavelength}")
# Monitor temperatures
temps = laser.get_all_temperatures()
print(f"Main temperature: {temps.main}°C")
# Control power
laser.set_power_command(100.0)
power = laser.get_power_command()
print(f"Power set to: {power} mW")
```
### Using Real Hardware
```python
from coherent_hops_laser import CoherentHOPSLaser, I2CMode
# Initialize laser controller
laser = CoherentHOPSLaser(
slave_address=0x50, # I2C slave address
i2c_mode=I2CMode.STANDARD # 100 kHz
)
# Connect to FTDI device
laser.connect('ftdi://ftdi:2232/1')
try:
# Query laser model
model = laser.get_laser_model()
print(f"Laser Model: {model}")
# Get all temperatures
temps = laser.get_all_temperatures()
print(f"Temperatures: {temps}")
# Set power
laser.set_power_command(50.0)
# Check control mode
mode = laser.get_control_mode()
print(f"Control Mode: {mode}")
finally:
laser.disconnect()
```
### Using Context Manager
```python
from coherent_hops_laser import CoherentHOPSLaser
with CoherentHOPSLaser() as laser:
laser.connect()
# Your laser control code here
info = laser.get_system_info()
print(info)
# Automatically disconnects
```
## Available Commands
### System Information
- `get_hardware_id()` - Hardware ID
- `get_head_type()` - Head type
- `get_head_board_revision()` - PCB revision
- `get_laser_model()` - Laser model (G532, Tina, Mini00, MiniX)
- `get_power_units()` - Power units (mW, W)
- `get_wavelength()` - Wavelength (e.g., 532nm)
- `get_system_info()` - All system info at once
### Temperature Monitoring
- `get_temperature_main()` - Main heatsink temperature
- `get_temperature_brf()` - BRF temperature
- `get_temperature_shg()` - SHG temperature
- `get_temperature_thg()` - THG temperature
- `get_temperature_eta()` - ETA temperature
- `get_all_temperatures()` - All temperatures at once
### Temperature Control
- `get_temperature_setpoint_main()` / `set_temperature_setpoint_main(temp)`
- `get_temperature_setpoint_brf()` / `set_temperature_setpoint_brf(temp)`
- `get_temperature_setpoint_shg()` / `set_temperature_setpoint_shg(temp)`
- `get_temperature_setpoint_thg()` / `set_temperature_setpoint_thg(temp)`
- `get_temperature_setpoint_eta()` / `set_temperature_setpoint_eta(temp)`
### Power Control
- `get_power_command()` / `set_power_command(power)` - Get/set power
- `get_power_memory()` - Stored power settings
- `get_power_limits()` - Power limits
### Current Control
- `get_current_command()` / `set_current_command(current)` - Get/set current
- `get_current_limits()` - Current limits
- `get_control_mode()` / `set_control_mode(mode)` - Control mode (POWER/CURRENT)
### Status Monitoring
- `get_key_switch_status()` - Key switch status
- `get_fan_status()` / `set_fan_control(value)` - Fan control
- `get_interlock_status()` - Interlock status
- `get_remote_control_status()` - Remote control status
- `get_analog_values()` - Analog sensor values
### Configuration
- `get_config_register_0()` / `set_config_register_0(value)`
- `get_config_register_1()` / `set_config_register_1(value)`
- `get_config_register_2()` / `set_config_register_2(value)`
- `get_config_register_3()` / `set_config_register_3(value)`
See the [API documentation](LASER_I2C_PROTOCOL.md) for complete command reference.
## Examples
Run the example script:
```bash
# Simulation mode (no hardware)
python3 example_usage.py 1
# Real hardware mode
python3 example_usage.py 2
# Continuous monitoring
python3 example_usage.py 3
```
Or run the built-in test:
```bash
python3 coherent_hops_laser.py
```
## Continuous Monitoring Example
```python
from coherent_hops_laser import CoherentHOPSLaser
import time
with CoherentHOPSLaser() as laser:
laser.connect()
while True:
temps = laser.get_all_temperatures()
power = laser.get_power_command()
print(f"Main: {temps.main:.1f}°C Power: {power:.1f}mW")
time.sleep(1)
```
## Troubleshooting
### Cannot find FTDI device
```bash
# Check if device is connected
lsusb | grep FTDI
# Should show something like:
# Bus 001 Device 005: ID 0403:6010 Future Technology Devices International, Ltd FT2232C
```
### Permission denied
Add your user to the dialout/plugdev group:
```bash
sudo usermod -a -G dialout $USER
sudo usermod -a -G plugdev $USER
```
Then log out and back in.
### I2C communication errors
- Verify correct slave address (default: 0x50)
- Check I2C speed (try I2CMode.STANDARD instead of FAST)
- Verify physical connections
- Check for other devices on the bus
### Import errors
```bash
# Install pyftdi
pip install pyftdi
# If that fails, try:
pip install --user pyftdi
```
## Architecture
- **CoherentHOPSLaser**: Main class for real hardware control
- **DummyLaser**: Simulator for development without hardware
- **I2CController**: Low-level FTDI I2C communication (from pyftdi)
- **LaserInfo / TemperatureStatus**: Data classes for structured responses
## Safety Notes
- Always verify power levels before enabling laser output
- Monitor temperatures during operation
- Check interlock status before operation
- Use appropriate laser safety equipment
- Follow all manufacturer safety guidelines
## License
This implementation is based on the Coherent HOPS Demo v2.0.7 protocol documentation.
## References
- FTDI MPSSE Documentation: https://ftdichip.com/software-examples/mpsse-projects/
- PyFTDI: https://github.com/eblot/pyftdi
- Original Protocol Documentation: [LASER_I2C_PROTOCOL.md](LASER_I2C_PROTOCOL.md)
-773
View File
@@ -1,773 +0,0 @@
"""
Coherent HOPS Laser I2C Control Library
This module provides a Python interface to control Coherent HOPS laser systems
via I2C protocol through an FTDI FT2232C USB interface.
Dependencies:
pip install pyftdi
Usage:
from coherent_hops_laser import CoherentHOPSLaser
laser = CoherentHOPSLaser()
laser.connect()
# Query system information
model = laser.get_laser_model()
wavelength = laser.get_wavelength()
# Monitor temperatures
main_temp = laser.get_temperature_main()
# Control power
laser.set_power_command(100.0) # Set power in mW or W
laser.disconnect()
"""
from typing import Optional, Union, List
from dataclasses import dataclass
from enum import Enum
import time
import logging
try:
from pyftdi.i2c import I2cController, I2cNackError
except ImportError:
raise ImportError(
"pyftdi library is required. Install with: pip install pyftdi"
)
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class I2CMode(Enum):
"""I2C communication modes"""
STANDARD = 100000 # 100 kHz
FAST = 400000 # 400 kHz
class ControlMode(Enum):
"""Laser control modes"""
POWER = "POWER"
CURRENT = "CURRENT"
@dataclass
class LaserInfo:
"""Laser system information"""
hardware_id: Optional[str] = None
head_type: Optional[str] = None
head_board_revision: Optional[str] = None
laser_model: Optional[str] = None
power_units: Optional[str] = None
wavelength: Optional[str] = None
@dataclass
class TemperatureStatus:
"""Temperature monitoring data"""
main: Optional[float] = None
brf: Optional[float] = None
shg: Optional[float] = None
thg: Optional[float] = None
eta: Optional[float] = None
class CoherentHOPSLaser:
"""
Main interface class for Coherent HOPS laser control via I2C.
This class provides high-level methods for all documented laser commands
including system queries, temperature control, power/current management,
and digital I/O operations.
"""
# Default I2C slave address for NXP microcontroller
DEFAULT_SLAVE_ADDRESS = 0x50
# FTDI USB VID/PID for FT2232C
FTDI_VID = 0x0403
FTDI_PID = 0x6010
def __init__(
self,
slave_address: int = DEFAULT_SLAVE_ADDRESS,
i2c_mode: I2CMode = I2CMode.STANDARD,
timeout: float = 1.0
):
"""
Initialize the laser controller.
Args:
slave_address: I2C slave address of the NXP microcontroller
i2c_mode: I2C communication speed mode
timeout: Command timeout in seconds
"""
self.slave_address = slave_address
self.i2c_mode = i2c_mode
self.timeout = timeout
self._i2c_controller = I2cController()
self._i2c_slave = None
self._connected = False
def connect(self, url: str = 'ftdi://ftdi:2232/1') -> None:
"""
Connect to the FTDI I2C device.
Args:
url: FTDI device URL (default: first FT2232C device, channel 1)
Examples:
- 'ftdi://ftdi:2232/1' - First FT2232 device, channel 1
- 'ftdi://ftdi:2232:SERIAL/1' - Device with specific serial number
Raises:
IOError: If connection fails
"""
try:
# Configure I2C controller
self._i2c_controller.configure(url, frequency=self.i2c_mode.value)
# Get I2C slave interface
self._i2c_slave = self._i2c_controller.get_port(self.slave_address)
self._connected = True
logger.info(f"Connected to laser at I2C address 0x{self.slave_address:02X}")
except Exception as e:
logger.error(f"Failed to connect to I2C device: {e}")
raise IOError(f"I2C connection failed: {e}")
def disconnect(self) -> None:
"""Disconnect from the I2C device."""
if self._connected:
self._i2c_controller.terminate()
self._connected = False
logger.info("Disconnected from laser")
def _ensure_connected(self) -> None:
"""Verify device is connected before operations."""
if not self._connected:
raise RuntimeError("Not connected. Call connect() first.")
def _send_command(self, command: str, value: Optional[str] = None) -> str:
"""
Send a command to the laser and read response.
Args:
command: Command string (e.g., 'HID', 'LASERMODEL')
value: Optional value for set commands
Returns:
Response string from the laser
Raises:
I2cNackError: If I2C communication fails
TimeoutError: If response timeout occurs
"""
self._ensure_connected()
# Format command: query = ?COMMAND, set = COMMAND=VALUE
if value is not None:
cmd_str = f"{command}={value}"
else:
cmd_str = f"?{command}"
cmd_bytes = cmd_str.encode('ascii')
try:
# Write command
self._i2c_slave.write(cmd_bytes)
# Small delay for laser to process
time.sleep(0.01)
# Read response (max 256 bytes)
response = self._i2c_slave.read(256)
# Decode and strip null bytes and whitespace
result = response.decode('ascii', errors='ignore').rstrip('\x00').strip()
logger.debug(f"Command: {cmd_str} -> Response: {result}")
return result
except I2cNackError as e:
logger.error(f"I2C NACK error for command {cmd_str}: {e}")
raise
except Exception as e:
logger.error(f"Communication error for command {cmd_str}: {e}")
raise
# ==========================================
# System Information Commands
# ==========================================
def get_hardware_id(self) -> str:
"""Query Hardware ID."""
return self._send_command('HID')
def get_head_type(self) -> str:
"""Query Head Type."""
return self._send_command('HTYPE')
def get_head_board_revision(self) -> str:
"""Query Head Board Revision."""
return self._send_command('HBDREV')
def get_head_digital_io(self) -> str:
"""Query Head Digital I/O configuration."""
return self._send_command('HEADDIO')
def get_laser_model(self) -> str:
"""
Query Laser Model.
Returns:
Model name (e.g., 'G532', 'Tina', 'Mini00', 'MiniX')
"""
return self._send_command('LASERMODEL')
def get_power_units(self) -> str:
"""
Query Power Units.
Returns:
Power units (e.g., 'mW', 'W')
"""
return self._send_command('POWERUNITS')
def get_wavelength(self) -> str:
"""
Query Wavelength.
Returns:
Wavelength (e.g., '532nm')
"""
return self._send_command('WAVELENGTH')
def get_system_info(self) -> LaserInfo:
"""
Query all system information.
Returns:
LaserInfo dataclass with all system parameters
"""
return LaserInfo(
hardware_id=self.get_hardware_id(),
head_type=self.get_head_type(),
head_board_revision=self.get_head_board_revision(),
laser_model=self.get_laser_model(),
power_units=self.get_power_units(),
wavelength=self.get_wavelength()
)
# ==========================================
# Temperature Monitoring
# ==========================================
def get_temperature_main(self) -> float:
"""
Get Main Heatsink Temperature.
Returns:
Temperature in degrees Celsius
"""
response = self._send_command('TMAIN')
return float(response)
def get_temperature_brf(self) -> float:
"""
Get BRF (Birefringent Filter) Temperature.
Returns:
Temperature in degrees Celsius
"""
response = self._send_command('TBRF')
return float(response)
def get_temperature_shg(self) -> float:
"""
Get SHG (Second Harmonic Generator) Temperature.
Returns:
Temperature in degrees Celsius
"""
response = self._send_command('TSHG')
return float(response)
def get_temperature_thg(self) -> float:
"""
Get THG (Third Harmonic Generator) Temperature.
Returns:
Temperature in degrees Celsius
"""
response = self._send_command('TTHG')
return float(response)
def get_temperature_eta(self) -> float:
"""
Get ETA Temperature.
Returns:
Temperature in degrees Celsius
"""
response = self._send_command('TETA')
return float(response)
def get_all_temperatures(self) -> TemperatureStatus:
"""
Query all temperature sensors.
Returns:
TemperatureStatus dataclass with all temperature readings
"""
return TemperatureStatus(
main=self.get_temperature_main(),
brf=self.get_temperature_brf(),
shg=self.get_temperature_shg(),
thg=self.get_temperature_thg(),
eta=self.get_temperature_eta()
)
# ==========================================
# Temperature Control (Setpoints)
# ==========================================
def get_temperature_setpoint_main(self) -> float:
"""Get Main Temperature Setpoint."""
response = self._send_command('TMAINCMD')
return float(response)
def set_temperature_setpoint_main(self, temperature: float) -> None:
"""Set Main Temperature Setpoint."""
self._send_command('TMAINCMD', str(temperature))
def get_temperature_setpoint_brf(self) -> float:
"""Get BRF Temperature Setpoint."""
response = self._send_command('TBRFCMD')
return float(response)
def set_temperature_setpoint_brf(self, temperature: float) -> None:
"""Set BRF Temperature Setpoint."""
self._send_command('TBRFCMD', str(temperature))
def get_temperature_setpoint_shg(self) -> float:
"""Get SHG Temperature Setpoint."""
response = self._send_command('TSHGCMD')
return float(response)
def set_temperature_setpoint_shg(self, temperature: float) -> None:
"""Set SHG Temperature Setpoint."""
self._send_command('TSHGCMD', str(temperature))
def get_temperature_setpoint_thg(self) -> float:
"""Get THG Temperature Setpoint."""
response = self._send_command('TTHGCMD')
return float(response)
def set_temperature_setpoint_thg(self, temperature: float) -> None:
"""Set THG Temperature Setpoint."""
self._send_command('TTHGCMD', str(temperature))
def get_temperature_setpoint_eta(self) -> float:
"""Get ETA Temperature Setpoint."""
response = self._send_command('TETACMD')
return float(response)
def set_temperature_setpoint_eta(self, temperature: float) -> None:
"""Set ETA Temperature Setpoint."""
self._send_command('TETACMD', str(temperature))
# ==========================================
# Temperature Data
# ==========================================
def get_temperature_data_main(self) -> str:
"""Get Main Temperature Data."""
return self._send_command('MAIND')
def get_temperature_data_brf(self) -> str:
"""Get BRF Temperature Data."""
return self._send_command('BRFD')
def get_temperature_data_shg(self) -> str:
"""Get SHG Temperature Data."""
return self._send_command('SHGD')
def get_temperature_data_thg(self) -> str:
"""Get THG Temperature Data."""
return self._send_command('THGD')
def get_temperature_data_eta(self) -> str:
"""Get ETA Temperature Data."""
return self._send_command('ETAD')
# ==========================================
# Power Control
# ==========================================
def get_power_command(self) -> float:
"""
Get Power Command value.
Returns:
Power value in units from get_power_units()
"""
response = self._send_command('PCMD')
return float(response)
def set_power_command(self, power: float) -> None:
"""
Set Power Command value.
Args:
power: Power value in units from get_power_units()
"""
self._send_command('PCMD', str(power))
def get_power_memory(self) -> str:
"""Query Power Memory (stored settings)."""
return self._send_command('PMEM')
def get_power_limits(self) -> str:
"""Query Power Limits."""
return self._send_command('PLIM')
# ==========================================
# Current Control
# ==========================================
def get_current_command(self) -> float:
"""
Get Current Command value.
Returns:
Current value in Amperes
"""
response = self._send_command('CCMD')
return float(response)
def set_current_command(self, current: float) -> None:
"""
Set Current Command value.
Args:
current: Current value in Amperes
"""
self._send_command('CCMD', str(current))
def get_current_limits(self) -> str:
"""Query Current Limits."""
return self._send_command('CLIM')
def get_control_mode(self) -> str:
"""
Get Control Mode.
Returns:
Control mode (e.g., 'POWER' or 'CURRENT')
"""
return self._send_command('CMODE')
def set_control_mode(self, mode: Union[str, ControlMode]) -> None:
"""
Set Control Mode.
Args:
mode: Control mode ('POWER' or 'CURRENT', or ControlMode enum)
"""
if isinstance(mode, ControlMode):
mode = mode.value
self._send_command('CMODE', mode)
def get_control_mode_command(self) -> str:
"""Get Control Mode Command."""
return self._send_command('CMODECMD')
def set_control_mode_command(self, mode: str) -> None:
"""Set Control Mode Command."""
self._send_command('CMODECMD', mode)
# ==========================================
# Digital I/O
# ==========================================
def get_ps_digital_io(self) -> str:
"""Get Power Supply Digital I/O status."""
return self._send_command('PSDIO')
def get_ps_glue_input(self) -> str:
"""Get Power Supply Glue Logic Input status."""
return self._send_command('PSGLUEIN')
def get_ps_glue_output(self) -> str:
"""Get Power Supply Glue Logic Output status."""
return self._send_command('PSGLUEOUT')
def set_ps_glue_output(self, value: str) -> None:
"""Set Power Supply Glue Logic Output."""
self._send_command('PSGLUEOUT', value)
# ==========================================
# Monitoring & Status
# ==========================================
def get_analog_values(self) -> str:
"""Query Analog Values."""
return self._send_command('ANA')
def get_analog_command(self) -> str:
"""Get Analog Command."""
return self._send_command('ANACMD')
def set_analog_command(self, value: str) -> None:
"""Set Analog Command."""
self._send_command('ANACMD', value)
def get_key_switch_status(self) -> str:
"""Get Key Switch Status."""
return self._send_command('KSW')
def get_key_switch_command(self) -> str:
"""Get Key Switch Command."""
return self._send_command('KSWCMD')
def set_key_switch_command(self, value: str) -> None:
"""Set Key Switch Command."""
self._send_command('KSWCMD', value)
def get_fan_status(self) -> str:
"""Get Fan Status/Control."""
return self._send_command('FAN')
def set_fan_control(self, value: str) -> None:
"""Set Fan Control."""
self._send_command('FAN', value)
def get_interlock_status(self) -> str:
"""Get Interlock Status."""
return self._send_command('INT')
def get_remote_control_status(self) -> str:
"""Get Remote Control Status."""
return self._send_command('REM')
def get_eeprom_header(self) -> str:
"""Get EEPROM Header."""
return self._send_command('EEH')
# ==========================================
# Configuration Registers
# ==========================================
def get_config_register_0(self) -> str:
"""Get Configuration Register 0."""
return self._send_command('CFG0')
def set_config_register_0(self, value: str) -> None:
"""Set Configuration Register 0."""
self._send_command('CFG0', value)
def get_config_register_1(self) -> str:
"""Get Configuration Register 1."""
return self._send_command('CFG1')
def set_config_register_1(self, value: str) -> None:
"""Set Configuration Register 1."""
self._send_command('CFG1', value)
def get_config_register_2(self) -> str:
"""Get Configuration Register 2."""
return self._send_command('CFG2')
def set_config_register_2(self, value: str) -> None:
"""Set Configuration Register 2."""
self._send_command('CFG2', value)
def get_config_register_3(self) -> str:
"""Get Configuration Register 3."""
return self._send_command('CFG3')
def set_config_register_3(self, value: str) -> None:
"""Set Configuration Register 3."""
self._send_command('CFG3', value)
# ==========================================
# Context Manager Support
# ==========================================
def __enter__(self):
"""Context manager entry."""
if not self._connected:
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.disconnect()
return False
class DummyLaser(CoherentHOPSLaser):
"""
Simulated laser for testing without hardware.
This class provides dummy responses for all commands to enable
software development and testing without physical hardware.
"""
def __init__(self):
"""Initialize dummy laser (no I2C connection needed)."""
super().__init__()
self._connected = True # Simulate connection
# Simulated state
self._power_cmd = 50.0
self._current_cmd = 1.5
self._control_mode = 'POWER'
self._temps = {
'main': 25.0,
'brf': 30.0,
'shg': 35.0,
'thg': 32.0,
'eta': 28.0
}
self._temp_setpoints = {
'main': 25.0,
'brf': 30.0,
'shg': 35.0,
'thg': 32.0,
'eta': 28.0
}
def connect(self, url: str = 'dummy') -> None:
"""Dummy connection (always succeeds)."""
self._connected = True
logger.info("Connected to DummyLaser (simulation mode)")
def _send_command(self, command: str, value: Optional[str] = None) -> str:
"""Simulate command responses."""
logger.debug(f"DummyLaser command: {command}, value: {value}")
# Handle set commands
if value is not None:
if command == 'PCMD':
self._power_cmd = float(value)
return value
elif command == 'CCMD':
self._current_cmd = float(value)
return value
elif command == 'CMODE':
self._control_mode = value
return value
elif 'CMD' in command and 'T' in command:
# Temperature setpoint
key = command.replace('CMD', '').replace('T', '').lower()
if key in self._temp_setpoints:
self._temp_setpoints[key] = float(value)
return value
return 'OK'
# Handle query commands
responses = {
'HID': 'HOPS-12345',
'HTYPE': 'Standard',
'HBDREV': 'Rev 2.1',
'HEADDIO': '0xFF',
'LASERMODEL': 'G532',
'POWERUNITS': 'mW',
'WAVELENGTH': '532nm',
'TMAIN': str(self._temps['main']),
'TBRF': str(self._temps['brf']),
'TSHG': str(self._temps['shg']),
'TTHG': str(self._temps['thg']),
'TETA': str(self._temps['eta']),
'TMAINCMD': str(self._temp_setpoints['main']),
'TBRFCMD': str(self._temp_setpoints['brf']),
'TSHGCMD': str(self._temp_setpoints['shg']),
'TTHGCMD': str(self._temp_setpoints['thg']),
'TETACMD': str(self._temp_setpoints['eta']),
'MAIND': 'MainTempData',
'BRFD': 'BRFTempData',
'SHGD': 'SHGTempData',
'THGD': 'THGTempData',
'ETAD': 'ETATempData',
'PCMD': str(self._power_cmd),
'PMEM': '100',
'PLIM': '0-200',
'CCMD': str(self._current_cmd),
'CLIM': '0-5',
'CMODE': self._control_mode,
'CMODECMD': self._control_mode,
'PSDIO': '0x00',
'PSGLUEIN': '0x00',
'PSGLUEOUT': '0x00',
'ANA': '0,0,0,0',
'ANACMD': '0',
'KSW': 'ON',
'KSWCMD': 'ON',
'FAN': 'AUTO',
'INT': 'OK',
'REM': 'ENABLED',
'EEH': 'EEPROM_V1',
'CFG0': '0x00',
'CFG1': '0x00',
'CFG2': '0x00',
'CFG3': '0x00',
}
return responses.get(command, 'UNKNOWN')
if __name__ == '__main__':
"""Example usage and testing."""
# Test with dummy laser
print("=== Testing with DummyLaser ===\n")
with DummyLaser() as laser:
# System info
print("System Information:")
info = laser.get_system_info()
print(f" Model: {info.laser_model}")
print(f" Wavelength: {info.wavelength}")
print(f" Power Units: {info.power_units}")
print(f" Hardware ID: {info.hardware_id}")
print()
# Temperature monitoring
print("Temperature Status:")
temps = laser.get_all_temperatures()
print(f" Main: {temps.main}°C")
print(f" BRF: {temps.brf}°C")
print(f" SHG: {temps.shg}°C")
print(f" THG: {temps.thg}°C")
print(f" ETA: {temps.eta}°C")
print()
# Power control
print("Power Control:")
current_power = laser.get_power_command()
print(f" Current Power: {current_power} {info.power_units}")
laser.set_power_command(75.0)
new_power = laser.get_power_command()
print(f" New Power: {new_power} {info.power_units}")
print()
# Control mode
print("Control Mode:")
mode = laser.get_control_mode()
print(f" Current Mode: {mode}")
print()
print("\n=== For real hardware, use: ===")
print("laser = CoherentHOPSLaser()")
print("laser.connect('ftdi://ftdi:2232/1')")
print("# ... perform operations ...")
print("laser.disconnect()")
-256
View File
@@ -1,256 +0,0 @@
#!/usr/bin/env python3
"""
Example usage of the Coherent HOPS Laser control library.
This script demonstrates how to use the library to control a real laser system.
"""
from coherent_hops_laser import CoherentHOPSLaser, DummyLaser, I2CMode
import time
def demo_system_info(laser):
"""Demonstrate system information queries."""
print("=" * 60)
print("SYSTEM INFORMATION")
print("=" * 60)
info = laser.get_system_info()
print(f"Hardware ID: {info.hardware_id}")
print(f"Laser Model: {info.laser_model}")
print(f"Wavelength: {info.wavelength}")
print(f"Power Units: {info.power_units}")
print(f"Head Type: {info.head_type}")
print(f"Board Revision: {info.head_board_revision}")
print()
def demo_temperature_monitoring(laser):
"""Demonstrate temperature monitoring."""
print("=" * 60)
print("TEMPERATURE MONITORING")
print("=" * 60)
temps = laser.get_all_temperatures()
print(f"Main Heatsink: {temps.main:.2f}°C")
print(f"BRF (Birefringent): {temps.brf:.2f}°C")
print(f"SHG (2nd Harmonic): {temps.shg:.2f}°C")
print(f"THG (3rd Harmonic): {temps.thg:.2f}°C")
print(f"ETA: {temps.eta:.2f}°C")
print()
def demo_temperature_control(laser):
"""Demonstrate temperature setpoint control."""
print("=" * 60)
print("TEMPERATURE CONTROL")
print("=" * 60)
# Read current setpoints
print("Current Setpoints:")
print(f" Main: {laser.get_temperature_setpoint_main():.2f}°C")
print(f" BRF: {laser.get_temperature_setpoint_brf():.2f}°C")
print(f" SHG: {laser.get_temperature_setpoint_shg():.2f}°C")
print()
# Example: Set a new setpoint (commented out for safety)
# print("Setting Main temperature setpoint to 26.0°C...")
# laser.set_temperature_setpoint_main(26.0)
# print(f" New setpoint: {laser.get_temperature_setpoint_main():.2f}°C")
print("(Temperature setpoint modification disabled in demo)")
print()
def demo_power_control(laser):
"""Demonstrate power control."""
print("=" * 60)
print("POWER CONTROL")
print("=" * 60)
units = laser.get_power_units()
current_power = laser.get_power_command()
print(f"Current Power: {current_power} {units}")
power_limits = laser.get_power_limits()
print(f"Power Limits: {power_limits}")
power_memory = laser.get_power_memory()
print(f"Power Memory: {power_memory}")
print()
# Example: Set power (commented out for safety)
# print("Setting power to 100.0 mW...")
# laser.set_power_command(100.0)
# print(f" New power: {laser.get_power_command()} {units}")
print("(Power modification disabled in demo)")
print()
def demo_current_control(laser):
"""Demonstrate current control."""
print("=" * 60)
print("CURRENT CONTROL")
print("=" * 60)
current = laser.get_current_command()
print(f"Current Command: {current} A")
limits = laser.get_current_limits()
print(f"Current Limits: {limits}")
mode = laser.get_control_mode()
print(f"Control Mode: {mode}")
print()
def demo_status_monitoring(laser):
"""Demonstrate status monitoring."""
print("=" * 60)
print("STATUS MONITORING")
print("=" * 60)
print(f"Key Switch: {laser.get_key_switch_status()}")
print(f"Fan Status: {laser.get_fan_status()}")
print(f"Interlock: {laser.get_interlock_status()}")
print(f"Remote Control: {laser.get_remote_control_status()}")
print(f"Analog Values: {laser.get_analog_values()}")
print()
def demo_configuration(laser):
"""Demonstrate configuration register access."""
print("=" * 60)
print("CONFIGURATION REGISTERS")
print("=" * 60)
print(f"Config Register 0: {laser.get_config_register_0()}")
print(f"Config Register 1: {laser.get_config_register_1()}")
print(f"Config Register 2: {laser.get_config_register_2()}")
print(f"Config Register 3: {laser.get_config_register_3()}")
print()
def main_dummy_demo():
"""Run demo with simulated hardware."""
print("\n" + "=" * 60)
print("COHERENT HOPS LASER CONTROL - SIMULATION MODE")
print("=" * 60 + "\n")
with DummyLaser() as laser:
demo_system_info(laser)
demo_temperature_monitoring(laser)
demo_temperature_control(laser)
demo_power_control(laser)
demo_current_control(laser)
demo_status_monitoring(laser)
demo_configuration(laser)
# Demonstrate power control
print("=" * 60)
print("POWER CONTROL DEMONSTRATION (Simulation)")
print("=" * 60)
print(f"Initial power: {laser.get_power_command()} mW")
laser.set_power_command(125.0)
print(f"After setting to 125.0 mW: {laser.get_power_command()} mW")
print()
def main_real_hardware():
"""Run demo with real hardware."""
print("\n" + "=" * 60)
print("COHERENT HOPS LASER CONTROL - REAL HARDWARE")
print("=" * 60 + "\n")
# Configure for your specific setup
FTDI_URL = 'ftdi://ftdi:2232/1' # Adjust if needed
SLAVE_ADDRESS = 0x50 # Default NXP slave address
I2C_FREQUENCY = I2CMode.STANDARD # or I2CMode.FAST
try:
# Connect to laser
laser = CoherentHOPSLaser(
slave_address=SLAVE_ADDRESS,
i2c_mode=I2C_FREQUENCY
)
print(f"Connecting to FTDI device at {FTDI_URL}...")
laser.connect(FTDI_URL)
print("Connected successfully!\n")
# Run demos
demo_system_info(laser)
demo_temperature_monitoring(laser)
demo_status_monitoring(laser)
demo_power_control(laser)
demo_current_control(laser)
# Clean disconnect
laser.disconnect()
print("Disconnected successfully.")
except Exception as e:
print(f"Error: {e}")
print("\nTroubleshooting:")
print("1. Check FTDI device is connected (lsusb | grep FTDI)")
print("2. Verify user permissions (add user to 'dialout' or 'plugdev' group)")
print("3. Check FTDI URL matches your device")
print("4. Try: sudo python3 example_usage.py (not recommended long-term)")
def continuous_monitoring_example():
"""Example of continuous temperature and power monitoring."""
print("\n" + "=" * 60)
print("CONTINUOUS MONITORING EXAMPLE")
print("=" * 60 + "\n")
with DummyLaser() as laser:
print("Monitoring laser parameters (5 iterations)...")
print("Press Ctrl+C to stop\n")
try:
for i in range(5):
temps = laser.get_all_temperatures()
power = laser.get_power_command()
mode = laser.get_control_mode()
print(f"[{i+1}] T_main={temps.main:.1f}°C "
f"T_shg={temps.shg:.1f}°C "
f"Power={power:.1f}mW "
f"Mode={mode}")
time.sleep(1)
except KeyboardInterrupt:
print("\nMonitoring stopped.")
if __name__ == '__main__':
import sys
print("Coherent HOPS Laser Control - Example Usage\n")
print("Available demos:")
print(" 1. Simulation mode (no hardware required)")
print(" 2. Real hardware mode")
print(" 3. Continuous monitoring example")
print()
if len(sys.argv) > 1:
choice = sys.argv[1]
else:
choice = input("Select demo (1/2/3) [default: 1]: ").strip() or "1"
if choice == "1":
main_dummy_demo()
print("\nContinuous monitoring demo:")
continuous_monitoring_example()
elif choice == "2":
main_real_hardware()
elif choice == "3":
continuous_monitoring_example()
else:
print("Invalid choice. Use 1, 2, or 3.")
sys.exit(1)
print("\n" + "=" * 60)
print("Demo complete!")
print("=" * 60)
-1
View File
@@ -1 +0,0 @@
pyftdi>=0.54.0
+4 -14
View File
@@ -1,19 +1,9 @@
# scanengine-3 - Unified Requirements
# Combined dependencies from nuescan, pymso, pybbd202, and pypewpewhops
# GUI Framework (from nuescan)
PyQt6>=6.4.0 PyQt6>=6.4.0
# Serial Communication (from nuescan)
pyserial>=3.5 pyserial>=3.5
# VISA instrument control (from nuescan - for oscilloscope/pymso)
pyvisa>=1.13.0 pyvisa>=1.13.0
pyvisa-py>=0.7.0 pyvisa-py>=0.7.0
# FTDI device support (from pypewpewhops and pybbd202)
pyftdi>=0.54.0 pyftdi>=0.54.0
pytest>=7.0.0
# Development dependencies (optional) pytest-qt>=4.0.0
# pytest>=7.0.0 pyueye>=4.95.0
# pytest-qt>=4.0.0 numpy>=1.20.0
+2
View File
@@ -0,0 +1,2 @@
"""ScanEngine-3 main application package"""
__version__ = "3.0.0"
+3432
View File
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>JogStageDialog</class>
<widget class="QDialog" name="JogStageDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>400</height>
</rect>
</property>
<property name="windowTitle">
<string>Jog Stage</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label_title">
<property name="font">
<font>
<pointsize>16</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Stage Jogging Control</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_position">
<property name="font">
<font>
<pointsize>12</pointsize>
</font>
</property>
<property name="text">
<string>Position: X=0.00mm, Y=0.00mm</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_top">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="1">
<widget class="QPushButton" name="btn_jog_y_plus">
<property name="minimumSize">
<size>
<width>80</width>
<height>60</height>
</size>
</property>
<property name="font">
<font>
<pointsize>14</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>+Y</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QPushButton" name="btn_jog_x_plus">
<property name="minimumSize">
<size>
<width>80</width>
<height>60</height>
</size>
</property>
<property name="font">
<font>
<pointsize>14</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>+X</string>
</property>
</widget>
</item>
<item row="1" column="1">
<layout class="QVBoxLayout" name="verticalLayout_center">
<item>
<widget class="QLabel" name="label_speed">
<property name="text">
<string>Jog Speed (mm/s):</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_jog_speed">
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="text">
<string>20.0</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_step">
<property name="text">
<string>Step Size (mm):</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_step_size">
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="text">
<string>1.0</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="2">
<widget class="QPushButton" name="btn_jog_x_minus">
<property name="minimumSize">
<size>
<width>80</width>
<height>60</height>
</size>
</property>
<property name="font">
<font>
<pointsize>14</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>-X</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="btn_jog_y_minus">
<property name="minimumSize">
<size>
<width>80</width>
<height>60</height>
</size>
</property>
<property name="font">
<font>
<pointsize>14</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>-Y</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer_bottom">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label_status">
<property name="text">
<string>Status: Disconnected</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_buttons">
<item>
<widget class="QPushButton" name="btn_connect">
<property name="text">
<string>Connect</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btn_home">
<property name="text">
<string>Home All Axes</string>
</property>
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<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>
<widget class="QPushButton" name="btn_close">
<property name="text">
<string>Close</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
+109
View File
@@ -0,0 +1,109 @@
<?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>653</width>
<height>360</height>
</rect>
</property>
<property name="font">
<font>
<family>Bahnschrift</family>
</font>
</property>
<property name="windowTitle">
<string>Scanengin3 | v3.1.0 | build: nottagain</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>Bahnschrift</family>
<pointsize>26</pointsize>
</font>
</property>
<property name="text">
<string>Scanengine Task Launcher:</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_2">
<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="QLabel" name="label_2">
<property name="font">
<font>
<family>Bahnschrift</family>
<pointsize>14</pointsize>
</font>
</property>
<property name="text">
<string>Choose a workflow:</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_start_new_scan">
<property name="text">
<string>Begin a New Scan</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_continue_scan">
<property name="text">
<string>Continue an Existing Scan</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_open_options">
<property name="text">
<string>Configure System / Set Default Values</string>
</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>
</layout>
</item>
</layout>
</widget>
<widget class="QStatusBar" name="statusbar"/>
</widget>
<resources/>
<connections/>
</ui>
+426
View File
@@ -0,0 +1,426 @@
"""
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.bbd202 import MotionController
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[MotionController] = 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 (active requests can be slow)
self.last_position_update_time = 0
self.position_update_interval = 0.2 # seconds between position requests
# 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 = MotionController()
self.controller.connect() # ACKs are sent reactively when enable_updates=True
# Enable channels
self.controller.set_channel_enable_state(self.controller.DEST_X_AXIS, True)
self.controller.set_channel_enable_state(self.controller.DEST_Y_AXIS, True)
# Request status updates for both axes to populate status bits (including homed state)
# This is necessary because status bits are not sent automatically after connect
self.controller.request_status_update(self.controller.DEST_X_AXIS)
self.controller.request_status_update(self.controller.DEST_Y_AXIS)
# Wait a moment for the asynchronous status update responses
time.sleep(0.2)
# Set initial velocity parameters
for dest in [self.controller.DEST_X_AXIS, self.controller.DEST_Y_AXIS]:
self.controller.set_velocity_params(
dest,
min_velocity=0.0,
acceleration=self.acceleration,
max_velocity=self.jog_speed
)
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:
# Determine destination
dest = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS
# Calculate relative distance
distance = self.step_size * direction
# Set relative move parameters
self.controller.set_move_rel_params(dest, distance)
# Execute the move (non-blocking - we don't wait for completion)
# Use a very short timeout since we're doing continuous jogging
self.controller.move_relative(dest, timeout=0.5)
# Update position
self.update_position()
self.move_completed.emit(axis)
except Exception as e:
# Don't emit errors for timeout - that's expected during continuous jog
if "timeout" not in str(e).lower():
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 = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS
print(f"Homing {axis.upper()} axis...")
self.controller.home_axis(dest, timeout=20.0)
# Update position and status after homing
self.update_position()
self.update_home_status()
print(f"{axis.upper()} axis homed successfully")
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 [self.controller.DEST_X_AXIS, self.controller.DEST_Y_AXIS]:
self.controller.set_velocity_params(
dest,
min_velocity=0.0,
acceleration=self.acceleration,
max_velocity=self.jog_speed
)
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 = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS
self.controller.set_channel_enable_state(dest, enabled)
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 requests to avoid slowing down the main loop
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:
# Actively request positions from the controller instead of relying on cached values
# This ensures we always have up-to-date position data
# Use longer timeout (1.5s) to accommodate high-speed scanning at 200mm/s
x_pos = self.controller.get_position(self.controller.DEST_X_AXIS, timeout=1.5)
y_pos = self.controller.get_position(self.controller.DEST_Y_AXIS, timeout=1.5)
if x_pos is not None and y_pos is not None:
# 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)
# else: silently skip incomplete position data during busy scanning
except RuntimeError as e:
# RuntimeError indicates actual hardware error (overtemp, encoder fault, etc.)
print(f"CRITICAL: Motor error detected: {e}")
self.error_occurred.emit(str(e))
# Stop requesting position updates to avoid spam
self.is_connected = False
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.is_homed_x
y_homed = self.controller.is_homed_y
# 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.
NOTE: On BBD202 firmware v2.1.5, the is_in_motion_x/y properties
do NOT reliably detect motion - they may always return False even
during active movement. This is a known firmware limitation.
For scan execution, use the ScanWorker.move_and_wait() method which
uses position-based motion detection instead of status bits.
This UI status display is best-effort only.
"""
if not self.is_connected or not self.controller:
return
try:
# Actively poll for status updates
self.controller.poll_status()
# Check for any error conditions
error_msg = self.controller.check_for_errors()
if error_msg:
print(f"Motor error detected: {error_msg}")
self.error_occurred.emit(error_msg)
self.controller.clear_last_error()
# Read the cached status (may not accurately reflect motion on some firmware)
x_moving = self.controller.is_in_motion_x
y_moving = self.controller.is_in_motion_y
# Also check if there are pending moves (more reliable)
if self.controller.is_move_pending():
# If there are pending moves, we're likely still moving
# This provides a backup indication when status bits fail
pending = self.controller.get_pending_targets()
if self.controller.DEST_X_AXIS in pending:
x_moving = True
if self.controller.DEST_Y_AXIS in pending:
y_moving = True
# 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'))
File diff suppressed because it is too large Load Diff
+743
View File
@@ -0,0 +1,743 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>681</width>
<height>471</height>
</rect>
</property>
<property name="font">
<font>
<family>Bahnschrift</family>
<pointsize>12</pointsize>
</font>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTabWidget" name="options_tab_widget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="genesis_config">
<attribute name="title">
<string>Detection Laser</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<layout class="QVBoxLayout" name="vl_genconfig_main">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<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>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Scanning Power [mW]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_detection_scanpower"/>
</item>
<item>
<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>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Test Connection:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_4">
<property name="text">
<string>???</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_test_genesis_connection">
<property name="text">
<string>Query Laser / Test Connection</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>Laser SN:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_detection_serialnum">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_16">
<property name="text">
<string>Laser Model:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_detection_modelname">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_7">
<item>
<widget class="QLabel" name="label_9">
<property name="text">
<string>Interlock State:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_detection_interlock">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_14">
<property name="text">
<string>Keyswitch State:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_detection_keyswitch">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Main Heatsink Temp</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_detection_heatsink_temp">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_12">
<property name="text">
<string>ETA Temp</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_detection_eta_temp">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="helios_config">
<attribute name="title">
<string>Generation Laser</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_9">
<item>
<layout class="QVBoxLayout" name="verticalLayout_8">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QLabel" name="label_17">
<property name="text">
<string>Communication Port:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_generation_comport"/>
</item>
<item>
<widget class="QPushButton" name="pb_autodetect_genlaser">
<property name="text">
<string>Autodetect</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_focusing_params">
<property name="font">
<font>
<pointsize>11</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Focusing Parameters:</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_focusing_freq">
<item>
<widget class="QLabel" name="label_focusing_frequency">
<property name="text">
<string>Focusing Frequency [Hz]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_generation_focusing_frequency"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_focusing_current">
<item>
<widget class="QLabel" name="label_focusing_current">
<property name="text">
<string>Focusing Pump Current [mA]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_pumpdiode_focusing_current"/>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer_focusing">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label_scanning_params">
<property name="font">
<font>
<pointsize>11</pointsize>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Scanning Parameters:</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_8">
<item>
<widget class="QLabel" name="label_18">
<property name="text">
<string>Scanning Frequency [Hz]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_generation_frequency"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_9">
<item>
<widget class="QLabel" name="label_19">
<property name="text">
<string>Scanning Pump Current [mA]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_pumpdiode_current"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_10">
<item>
<widget class="QLabel" name="label_20">
<property name="text">
<string>Effective Scan Direction Pixel Size:</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="l_calculated_pixel_size_laser">
<property name="text">
<string>mm px</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_7">
<item>
<widget class="QPushButton" name="le_generation_reset">
<property name="text">
<string>Reset Laser (Required after interlock enable)</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="mls_config">
<attribute name="title">
<string>Scanning Stage</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_11">
<item>
<layout class="QVBoxLayout" name="verticalLayout_10">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_13">
<item>
<widget class="QLabel" name="label_22">
<property name="text">
<string>Scan Velocity [mm/s]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_scan_velocity"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_15">
<item>
<widget class="QLabel" name="label_23">
<property name="text">
<string>Scan Acceleration [mm/s2]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_scan_accel"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_17">
<item>
<widget class="QLabel" name="label_24">
<property name="text">
<string>X-Axis Trigger Mode</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="combo_x_trigmode"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_19">
<item>
<widget class="QLabel" name="label_25">
<property name="text">
<string>Y-Axis Trigger Mode</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="combo_y_trigmode"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_14">
<item>
<widget class="QLabel" name="label_26">
<property name="text">
<string>Optical Axis X Coordinate [mm]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_optical_xcoord"/>
</item>
<item>
<widget class="QLabel" name="label_27">
<property name="text">
<string>Optical Axis Y Coordinate [mm]:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_optical_ycoord"/>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="t3r_config">
<attribute name="title">
<string>T3R</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_20">
<item>
<layout class="QVBoxLayout" name="verticalLayout_12">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_21">
<item>
<widget class="QLabel" name="label_28">
<property name="text">
<string>Comminucation Port:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_t3r_comport"/>
</item>
<item>
<widget class="QPushButton" name="pb_t3r_autodetect">
<property name="text">
<string>Autodetect</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_25">
<item>
<widget class="QPushButton" name="pb_t3r_test_connection">
<property name="text">
<string>Test Connection</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_24">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_29">
<property name="text">
<string>Firmware Version</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLabel" name="label_33">
<property name="text">
<string>FPGA Present?</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLabel" name="l_t3r_fpga_active">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="l_t3r_homed">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="l_t3r_fwversion">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_31">
<property name="text">
<string>Homed Status</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QLabel" name="label_35">
<property name="text">
<string>FPGA MultiDivider
Enabled?</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="0" column="4">
<widget class="QLabel" name="label_36">
<property name="text">
<string>FPGA RowPacking
Enabled?</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QLabel" name="l_t3r_fpga_multidivider">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="1" column="4">
<widget class="QLabel" name="l_t3r_fpga_rowpack">
<property name="text">
<string>???</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="scope_config">
<attribute name="title">
<string>Oscilloscope</string>
</attribute>
<layout class="QHBoxLayout" name="horizontalLayout_26">
<item>
<layout class="QVBoxLayout" name="verticalLayout_13">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_28">
<item>
<widget class="QLabel" name="label_39">
<property name="text">
<string>Oscilloscope Socket Address:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_oscope_socket_addr"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_30">
<item>
<widget class="QLabel" name="label_40">
<property name="text">
<string>Scratch Directory:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_data_scratchdir"/>
</item>
<item>
<widget class="QPushButton" name="pushButton_5">
<property name="text">
<string>Browse</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_29">
<item>
<widget class="QRadioButton" name="rdo_savetopc">
<property name="text">
<string>Save to PC</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="rdo_savetoscope">
<property name="text">
<string>Save to Oscilloscope</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_27">
<item>
<widget class="QPushButton" name="pb_test_scope">
<property name="text">
<string>Test Connection</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<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>
<widget class="QPushButton" name="pb_updateconfig">
<property name="text">
<string>Update Configuration</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_cancelconfig">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
+3
View File
@@ -0,0 +1,3 @@
"""Scan planning and modeling modules"""
from .sc3_scan_model import SC3ScanModel
from .stage_scan_plan_generator import *
+638
View File
@@ -0,0 +1,638 @@
"""
Scan Model Class.
Holds the scan configuration, and computes the
required start and end points for various specified angles.
Python implementation of SC3ScanModel.cs
"""
import math
from decimal import Decimal, InvalidOperation
from typing import List, Optional
import csv
class SC3ScanModel:
"""
Scan Model for generating scan paths and rotated scans.
Manages scan configuration and computes scan coordinates at various angles.
"""
def __init__(self):
# Private variables
self._x_origin: Decimal = Decimal('0.0')
self._y_origin: Decimal = Decimal('0.0')
self._x_delta: Decimal = Decimal('0.0')
self._y_delta: Decimal = Decimal('0.0')
self._row_spacing: Decimal = Decimal('0.0')
self._laser_frequency: Decimal = Decimal('2000.0') # Default: 2000 Hz
self._scan_velocity: Decimal = Decimal('100.0') # Default: 100 mm/s
self._scan_acceleration: Decimal = Decimal('0.0')
self._scan_angles: int = 0
self._points_required: int = 0
self._rows_required: int = 0
self._points_per_line: int = 0
# Optical axis centerline in stage coordinates
# Stage: MLS203-1
self._optical_x_origin: Decimal = Decimal('55.0')
self._optical_y_origin: Decimal = Decimal('37.5')
# Data storage
self._scan_coordinates: List[List[Decimal]] = []
self._scan_velocities: List[List[Decimal]] = []
self._scan_accelerations: List[List[Decimal]] = []
self._rotated_coordinates: List[List[List[Decimal]]] = []
# Constants
self._deg2rad: float = math.pi / 180.0
# Properties
@property
def x_origin(self) -> Decimal:
"""X coordinate of scan origin (mm)"""
return self._x_origin
@x_origin.setter
def x_origin(self, value: Decimal):
try:
self._x_origin = Decimal(str(value))
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid x_origin value: {value}") from e
@property
def y_origin(self) -> Decimal:
"""Y coordinate of scan origin (mm)"""
return self._y_origin
@y_origin.setter
def y_origin(self, value: Decimal):
try:
self._y_origin = Decimal(str(value))
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid y_origin value: {value}") from e
@property
def x_delta(self) -> Decimal:
"""Total X distance to scan (mm)"""
return self._x_delta
@x_delta.setter
def x_delta(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("x_delta must be non-negative")
self._x_delta = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid x_delta value: {value}") from e
@property
def y_delta(self) -> Decimal:
"""Total Y distance to scan (mm)"""
return self._y_delta
@y_delta.setter
def y_delta(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("y_delta must be non-negative")
self._y_delta = val
self.calculate_rows_required()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid y_delta value: {value}") from e
@property
def row_spacing(self) -> Decimal:
"""Spacing between scan rows (mm)"""
return self._row_spacing
@row_spacing.setter
def row_spacing(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("row_spacing must be non-negative")
self._row_spacing = val
self.calculate_rows_required()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid row_spacing value: {value}") from e
@property
def laser_frequency(self) -> Decimal:
"""Laser pulse frequency (Hz)"""
return self._laser_frequency
@laser_frequency.setter
def laser_frequency(self, value: Decimal):
try:
val = Decimal(str(value))
if val <= 0:
raise ValueError("laser_frequency must be positive")
self._laser_frequency = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid laser_frequency value: {value}") from e
@property
def scan_velocity(self) -> Decimal:
"""Scan velocity (mm/s)"""
return self._scan_velocity
@scan_velocity.setter
def scan_velocity(self, value: Decimal):
try:
val = Decimal(str(value))
if val <= 0:
raise ValueError("scan_velocity must be positive")
self._scan_velocity = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid scan_velocity value: {value}") from e
@property
def scan_acceleration(self) -> Decimal:
"""Scan acceleration (mm/s²)"""
return self._scan_acceleration
@scan_acceleration.setter
def scan_acceleration(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("scan_acceleration must be non-negative")
self._scan_acceleration = val
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid scan_acceleration value: {value}") from e
@property
def scan_angles(self) -> int:
"""Number of scan angles to compute"""
return self._scan_angles
@scan_angles.setter
def scan_angles(self, value: int):
if value < 0:
raise ValueError("scan_angles must be non-negative")
self._scan_angles = value
# Only compute rotated scans if we have base scan coordinates
if self._scan_coordinates:
self.compute_rotated_scans()
@property
def points_required(self) -> int:
"""Total number of points required for a single angle scan (computed)"""
return self._points_required
@property
def rows_required(self) -> int:
"""Number of rows required for the scan (computed)"""
return self._rows_required
@property
def points_per_line(self) -> int:
"""Number of points per scan line (computed)"""
return self._points_per_line
@property
def scan_coordinates(self) -> List[List[Decimal]]:
"""List of scan coordinates [x_start, y_start, x_end, y_end] in mm"""
return self._scan_coordinates
@property
def scan_velocities(self) -> List[List[Decimal]]:
"""List of velocity vectors [vx, vy] in mm/s for each angle"""
return self._scan_velocities
@property
def scan_accelerations(self) -> List[List[Decimal]]:
"""List of acceleration vectors [ax, ay] in mm/s² for each angle"""
return self._scan_accelerations
@property
def rotated_coordinates(self) -> List[List[List[Decimal]]]:
"""List of rotated scan coordinates for each angle in mm"""
return self._rotated_coordinates
@property
def optical_x_origin(self) -> Decimal:
"""X coordinate of optical axis origin in mm (read-only, MLS203-1 stage)"""
return self._optical_x_origin
@property
def optical_y_origin(self) -> Decimal:
"""Y coordinate of optical axis origin in mm (read-only, MLS203-1 stage)"""
return self._optical_y_origin
# Calculation Methods
def calculate_points_per_line(self):
"""
Calculates the number of data points per scan line based on
x_delta, scan_velocity, and laser_frequency.
Formula: points = (distance / velocity) * frequency
"""
if self._scan_velocity != 0:
self._points_per_line = int(
(self._x_delta / self._scan_velocity) * self._laser_frequency
)
def calculate_points_required(self):
"""
Calculates the total number of data points for a complete single-angle scan.
Also triggers computation of the zero-angle scan coordinates.
Formula: total_points = points_per_line * rows_required
"""
if self._rows_required != 0:
self._points_required = self._points_per_line * self._rows_required
self.compute_zero_scan()
def calculate_rows_required(self):
"""
Calculates the number of scan rows needed based on y_delta and row_spacing.
Formula: rows = ceil(y_delta / row_spacing)
"""
if self._row_spacing == 0:
self._rows_required = 0
else:
self._rows_required = int(math.ceil(self._y_delta / self._row_spacing))
def compute_zero_scan(self):
"""
Computes the zero-angle (reference) scan coordinates.
Each coordinate is [x_start, y_start, x_end, y_end].
"""
# Ignore the zero-row case
if self._rows_required == 0:
return
y_offset = Decimal('0.0')
self._scan_coordinates.clear()
for row in range(self._rows_required + 1):
# Calculate x/y origin/delta for each needed row
y_offset = Decimal(row) * self._row_spacing
coords = [
self._x_origin, # x_start
self._y_origin + y_offset, # y_start
self._x_origin + self._x_delta, # x_end
self._y_origin + y_offset # y_end (same as y_start for horizontal scan)
]
self._scan_coordinates.append(coords)
def _rotate_point(self, x: Decimal, y: Decimal, cosine: Decimal, sine: Decimal) -> tuple:
"""
Rotate a point around the optical axis origin.
Args:
x, y: Point coordinates to rotate
cosine, sine: Precomputed cos and sin of rotation angle
Returns:
Tuple of (rotated_x, rotated_y)
"""
# Rotation transformation:
# Xr = (X - Xo)*cos(a) + (Y - Yo)*sin(a) + Xo
# Yr = -(X - Xo)*sin(a) + (Y - Yo)*cos(a) + Yo
x_rot = ((x - self._optical_x_origin) * cosine +
(y - self._optical_y_origin) * sine +
self._optical_x_origin)
y_rot = (-(x - self._optical_x_origin) * sine +
(y - self._optical_y_origin) * cosine +
self._optical_y_origin)
return (x_rot, y_rot)
def _compute_rotated_aoi_bbox(self, angle_rad: float) -> tuple:
"""
Compute the bounding box of the rotated area of interest.
Rotates the four corners of the AoI rectangle and finds the
min/max extents to create a bounding box.
Args:
angle_rad: Rotation angle in radians
Returns:
Tuple of (min_x, min_y, max_x, max_y) as Decimals
"""
cosine = Decimal(str(math.cos(angle_rad)))
sine = Decimal(str(math.sin(angle_rad)))
# Define the four corners of the AoI rectangle
corners = [
(self._x_origin, self._y_origin),
(self._x_origin + self._x_delta, self._y_origin),
(self._x_origin + self._x_delta, self._y_origin + self._y_delta),
(self._x_origin, self._y_origin + self._y_delta)
]
# Rotate all corners
rotated_corners = []
for x, y in corners:
x_rot, y_rot = self._rotate_point(x, y, cosine, sine)
rotated_corners.append((x_rot, y_rot))
# Find bounding box extents
x_coords = [corner[0] for corner in rotated_corners]
y_coords = [corner[1] for corner in rotated_corners]
return (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
def compute_rotated_scans(self):
"""
Computes rotated scan coordinates by rotating the area of interest (AoI)
and generating horizontal (+x direction) scans through the bounding box
of the rotated AoI.
The rotation covers 0 to 180 degrees with spacing determined by scan_angles.
For each angle:
1. Rotate the AoI rectangle around the optical axis origin
2. Compute the bounding box of the rotated rectangle
3. Generate horizontal scan lines through the bounding box
"""
if self._rows_required == 0:
return
# Compute spacing between scans
if self._scan_angles == 0:
angle_spacing = 180
else:
angle_spacing = 180 / self._scan_angles
self._rotated_coordinates.clear()
# Iterate through each required angle from 0 to 180 degrees
i = 0
while i < 180:
current_angle_radians = i * (math.pi / 180.0)
# Get bounding box of rotated AoI
min_x, min_y, max_x, max_y = self._compute_rotated_aoi_bbox(current_angle_radians)
# Calculate the y extent of the bounding box
y_extent = max_y - min_y
# Determine number of rows needed for this bounding box
if self._row_spacing == 0:
num_rows = 0
else:
num_rows = int(math.ceil(y_extent / self._row_spacing))
temp_list = []
# Generate horizontal scan lines through the bounding box
for row in range(num_rows + 1):
y_offset = Decimal(row) * self._row_spacing
y_pos = min_y + y_offset
# Create horizontal scan line at this y position
scan_line = [
min_x, # x_start
y_pos, # y_start
max_x, # x_end
y_pos # y_end (same as y_start for horizontal scan)
]
temp_list.append(scan_line)
self._rotated_coordinates.append(temp_list)
i += int(round(angle_spacing))
def compute_kinematics(self, offset: int = 0):
"""
Computes velocity and acceleration component vectors for each scan angle.
For each angle, decomposes the scalar velocity and acceleration into
X and Y components based on the scan direction angle.
Args:
offset: Angle offset in degrees (default: 0)
"""
if self._scan_angles == 0:
scan_increment = 180
else:
scan_increment = 180 // self._scan_angles
self._scan_velocities.clear()
self._scan_accelerations.clear()
for i in range(self._scan_angles):
deg_angle = scan_increment * i + offset
angle_rad = deg_angle * self._deg2rad
# Compute velocity components: V = V_mag * [cos(θ), sin(θ)]
velocities = [
Decimal(str(math.cos(angle_rad))) * self._scan_velocity,
Decimal(str(math.sin(angle_rad))) * self._scan_velocity
]
# Compute acceleration components: A = A_mag * [cos(θ), sin(θ)]
accels = [
Decimal(str(math.cos(angle_rad))) * self._scan_acceleration,
Decimal(str(math.sin(angle_rad))) * self._scan_acceleration
]
self._scan_velocities.append(velocities)
self._scan_accelerations.append(accels)
# Export Methods
def export_zero_scan_csv(self, filename: Optional[str] = None) -> str:
"""
Export the zero-angle scan coordinates to a CSV file.
Args:
filename: Output filename. If None, generates from row count.
Returns:
The filename that was written
Raises:
ValueError: If no scan coordinates have been computed
IOError: If file cannot be written
"""
if not self._scan_coordinates:
raise ValueError("No scan coordinates available. Configure scan parameters first.")
if filename is None:
filename = f"scantest-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._scan_coordinates:
writer.writerow([str(c) for c in coords])
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
def export_rotated_scan_csv(self, angle_index: int, filename: Optional[str] = None) -> str:
"""
Export a specific rotated scan to CSV.
Args:
angle_index: Index of the angle to export (0-based)
filename: Output filename. If None, generates from angle and row count.
Returns:
The filename that was written
Raises:
ValueError: If angle_index is invalid or no rotated coordinates exist
IOError: If file cannot be written
"""
if not self._rotated_coordinates:
raise ValueError("No rotated coordinates available. Set scan_angles first.")
if angle_index < 0 or angle_index >= len(self._rotated_coordinates):
raise ValueError(
f"Invalid angle_index {angle_index}. Must be 0-{len(self._rotated_coordinates)-1}"
)
if filename is None:
angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180)
filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._rotated_coordinates[angle_index]:
writer.writerow([str(c) for c in coords])
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
def export_all_rotated_scans_csv(self, output_dir: str = ".") -> List[str]:
"""
Export all rotated scans to separate CSV files.
Args:
output_dir: Directory to write files to (default: current directory)
Returns:
List of filenames that were written
Raises:
ValueError: If no rotated coordinates exist
IOError: If files cannot be written
"""
if not self._rotated_coordinates:
raise ValueError("No rotated coordinates available. Set scan_angles first.")
import os
filenames = []
for angle_index in range(len(self._rotated_coordinates)):
angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180)
filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv"
filepath = os.path.join(output_dir, filename)
try:
with open(filepath, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._rotated_coordinates[angle_index]:
writer.writerow([str(c) for c in coords])
filenames.append(filepath)
except IOError as e:
raise IOError(f"Failed to write file {filepath}: {e}") from e
return filenames
def export_kinematics_csv(self, filename: Optional[str] = None) -> str:
"""
Export velocity and acceleration data to CSV.
Format: vx, vy, ax, ay for each angle.
Args:
filename: Output filename. If None, generates from row count.
Returns:
The filename that was written
Raises:
ValueError: If no kinematics data has been computed
IOError: If file cannot be written
"""
if not self._scan_velocities or not self._scan_accelerations:
raise ValueError("No kinematics data available. Call compute_kinematics() first.")
if filename is None:
filename = f"kinematics-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
# Optional: write header
writer.writerow(['vx', 'vy', 'ax', 'ay'])
for i in range(len(self._scan_velocities)):
row = [
str(self._scan_velocities[i][0]),
str(self._scan_velocities[i][1]),
str(self._scan_accelerations[i][0]),
str(self._scan_accelerations[i][1])
]
writer.writerow(row)
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
# Utility Methods
def get_angle_list(self) -> List[int]:
"""
Get the list of scan angles in degrees.
Returns:
List of angles in degrees for the configured scan_angles
"""
if self._scan_angles == 0:
return []
scan_increment = 180 // self._scan_angles
return [scan_increment * i for i in range(self._scan_angles)]
def get_scan_info(self) -> dict:
"""
Get a dictionary with current scan configuration and computed values.
Returns:
Dictionary containing scan parameters and computed values
"""
return {
'x_origin': float(self._x_origin),
'y_origin': float(self._y_origin),
'x_delta': float(self._x_delta),
'y_delta': float(self._y_delta),
'row_spacing': float(self._row_spacing),
'laser_frequency': float(self._laser_frequency),
'scan_velocity': float(self._scan_velocity),
'scan_acceleration': float(self._scan_acceleration),
'scan_angles': self._scan_angles,
'points_per_line': self._points_per_line,
'rows_required': self._rows_required,
'points_required': self._points_required,
'optical_x_origin': float(self._optical_x_origin),
'optical_y_origin': float(self._optical_y_origin),
'num_scan_coordinates': len(self._scan_coordinates),
'num_rotated_angles': len(self._rotated_coordinates),
'angle_list': self.get_angle_list(),
}
def __repr__(self) -> str:
"""String representation of the scan model."""
return (
f"SC3ScanModel("
f"origin=({self._x_origin},{self._y_origin}), "
f"delta=({self._x_delta},{self._y_delta}), "
f"rows={self._rows_required}, "
f"angles={self._scan_angles})"
)
+1
View File
@@ -0,0 +1 @@
"""Test modules for ScanEngine-3"""
+569
View File
@@ -0,0 +1,569 @@
"""
BBD202 Stage Diagnostic Tests
Diagnostic tests for the Thorlabs MLS203/BBD202 motion controller to:
1. Check if axes are properly homed
2. Move in a 15mm square pattern while outputting position data
3. Detect stage hangs through position monitoring
4. Output response information for debugging
"""
import sys
import time
from typing import Optional
from hardware.bbd202 import MotionController, MotorStatusBits
def format_status_bits(bits: Optional[int]) -> str:
"""Format status bits as a readable string with key flags."""
if bits is None:
return "None (no status received)"
flags = []
status = MotorStatusBits(bits)
# Key flags to check
if MotorStatusBits.HOMED in status:
flags.append("HOMED")
if MotorStatusBits.HOMING in status:
flags.append("HOMING")
if MotorStatusBits.ENABLED in status:
flags.append("ENABLED")
if MotorStatusBits.SETTLED in status:
flags.append("SETTLED")
if MotorStatusBits.TRACKING in status:
flags.append("TRACKING")
if MotorStatusBits.INMOTIONCW in status:
flags.append("INMOTIONCW")
if MotorStatusBits.INMOTIONCCW in status:
flags.append("INMOTIONCCW")
if MotorStatusBits.POWEROK in status:
flags.append("POWEROK")
if MotorStatusBits.ERROR in status:
flags.append("ERROR")
if MotorStatusBits.POSITIONERROR in status:
flags.append("POSITIONERROR")
if MotorStatusBits.OVERTEMP in status:
flags.append("OVERTEMP")
if MotorStatusBits.COMMUTATIONERROR in status:
flags.append("COMMUTATIONERROR")
flag_str = ", ".join(flags) if flags else "NO_FLAGS"
return f"0x{bits:08X} [{flag_str}]"
def test_homing_status(mc: MotionController) -> bool:
"""
Test 1: Check if both axes are properly homed.
Returns True if both axes are homed, False otherwise.
"""
print("\n" + "="*70)
print("TEST 1: Checking Homing Status")
print("="*70)
# Request fresh status from both axes
print("\nRequesting status update from both axes...")
mc.request_status_update(mc.DEST_X_AXIS)
mc.request_status_update(mc.DEST_Y_AXIS)
time.sleep(0.2) # Allow time for response
# Also poll status to ensure we have latest
mc.poll_status()
time.sleep(0.1)
# Check X-axis
x_bits = mc.status_bits_x
x_homed = mc.is_homed_x
print(f"\nX-Axis Status:")
print(f" Raw status bits: {format_status_bits(x_bits)}")
print(f" is_homed_x: {x_homed}")
# Check Y-axis
y_bits = mc.status_bits_y
y_homed = mc.is_homed_y
print(f"\nY-Axis Status:")
print(f" Raw status bits: {format_status_bits(y_bits)}")
print(f" is_homed_y: {y_homed}")
# Check for errors
last_err = mc.last_error
if last_err:
print(f"\n** Last Error: {last_err}")
error_check = mc.check_for_errors()
if error_check:
print(f"** Error condition detected: {error_check}")
# Summary
print(f"\n--- Homing Status Summary ---")
if x_homed and y_homed:
print("PASS: Both axes are homed")
return True
else:
print("FAIL: One or both axes are NOT homed")
if not x_homed:
print(" -> X-axis needs homing")
if not y_homed:
print(" -> Y-axis needs homing")
return False
def test_square_pattern(mc: MotionController, side_length: float = 15.0) -> bool:
"""
Test 2: Move the stage in a square pattern.
Moves in a 15mm square starting from current position, outputting
position data at each step to detect hangs.
Pattern: Start -> +X -> +Y -> -X -> -Y (back to start)
Uses the new wait_until_settled() method for reliable motion detection.
"""
print("\n" + "="*70)
print(f"TEST 2: Square Pattern Movement ({side_length}mm)")
print("="*70)
print("Using wait_until_settled() for motion detection")
# Get starting position
mc.poll_positions()
time.sleep(0.1)
start_x = mc.position_x
start_y = mc.position_y
if start_x is None or start_y is None:
print("ERROR: Could not get initial position")
return False
print(f"\nStarting position: X={start_x:.4f}mm, Y={start_y:.4f}mm")
# Define the square corners (relative to start)
corners = [
(start_x + side_length, start_y), # Move +X
(start_x + side_length, start_y + side_length), # Move +Y
(start_x, start_y + side_length), # Move -X
(start_x, start_y), # Move -Y (back to start)
]
corner_names = ["+X edge", "+Y edge (diagonal)", "-X edge", "Return to start"]
all_passed = True
for i, (target_x, target_y) in enumerate(corners):
print(f"\n--- Move {i+1}/4: {corner_names[i]} ---")
print(f"Target: X={target_x:.4f}mm, Y={target_y:.4f}mm")
# Clear any previous error
mc.clear_last_error()
# Record start time
move_start = time.time()
# Issue move command (non-blocking)
mc.move_to_fast(x=target_x, y=target_y)
# Use poll_until_idle() in a loop to show progress
print(f"\n{'Time':>8} {'X_pos':>12} {'Y_pos':>12} {'X_err':>10} {'Y_err':>10} {'Pending'}")
print("-" * 70)
sample_count = 0
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
elapsed = time.time() - move_start
curr_x = mc.position_x
curr_y = mc.position_y
if curr_x is not None and curr_y is not None:
x_err = curr_x - target_x
y_err = curr_y - target_y
# Print every 5th sample
if sample_count % 5 == 0:
pending = mc.get_pending_targets()
pending_str = ", ".join([f"{'X' if d==0x21 else 'Y'}" for d in pending.keys()])
print(f"{elapsed:>7.3f}s {curr_x:>12.4f} {curr_y:>12.4f} {x_err:>+10.4f} {y_err:>+10.4f} {pending_str:>8}")
sample_count += 1
# Timeout check
if elapsed > 30.0:
print(f"\n** TIMEOUT after 30s - stage may be hung!")
all_passed = False
mc.clear_pending_moves()
break
# Move complete - show final status
elapsed = time.time() - move_start
curr_x = mc.position_x
curr_y = mc.position_y
if curr_x is not None and curr_y is not None:
x_err = curr_x - target_x
y_err = curr_y - target_y
print(f"{elapsed:>7.3f}s {curr_x:>12.4f} {curr_y:>12.4f} {x_err:>+10.4f} {y_err:>+10.4f} ** DONE **")
print(f"\nMove completed in {elapsed:.3f}s")
print(f"Final position: X={curr_x:.4f}mm, Y={curr_y:.4f}mm")
print(f"Position error: X={abs(x_err)*1000:.1f}um, Y={abs(y_err)*1000:.1f}um")
# Check for errors after move
err = mc.check_for_errors()
if err:
print(f"** Error after move: {err}")
all_passed = False
last_err = mc.last_error
if last_err:
print(f"** Last error: {last_err}")
all_passed = False
# Short delay between moves
time.sleep(0.1)
# Final summary
print(f"\n--- Square Pattern Summary ---")
mc.poll_positions()
time.sleep(0.1)
final_x = mc.position_x
final_y = mc.position_y
if final_x is not None and final_y is not None:
total_x_err = abs(final_x - start_x)
total_y_err = abs(final_y - start_y)
print(f"Final position: X={final_x:.4f}mm, Y={final_y:.4f}mm")
print(f"Start position: X={start_x:.4f}mm, Y={start_y:.4f}mm")
print(f"Return error: X={total_x_err*1000:.1f}um, Y={total_y_err*1000:.1f}um")
if total_x_err > 0.05 or total_y_err > 0.05:
print("FAIL: Did not return to start position accurately (>50um)")
all_passed = False
if all_passed:
print("PASS: Square pattern completed successfully")
else:
print("FAIL: Issues detected during square pattern")
return all_passed
def print_diagnostic_info(mc: MotionController):
"""Print comprehensive diagnostic information about the controller."""
print("\n" + "="*70)
print("CONTROLLER DIAGNOSTIC INFO")
print("="*70)
try:
hw_info = mc.get_hw_info()
print(f"\nHardware Info:")
print(f" Serial Number: {hw_info['serial_number']}")
print(f" Model: {hw_info['model']}")
print(f" Firmware: {hw_info['firmware_version']}")
print(f" HW Version: {hw_info['hw_version']}")
print(f" Channels: {hw_info['num_channels']}")
print(f" Notes: {hw_info['notes']}")
except Exception as e:
print(f" Error getting hardware info: {e}")
# Request and display velocity params
print(f"\nVelocity Parameters:")
mc.send_command(0x0414, param1=0x01, dest=mc.DEST_X_AXIS) # REQ_VELPARAMS
mc.send_command(0x0414, param1=0x01, dest=mc.DEST_Y_AXIS)
time.sleep(0.1)
x_vel = mc.velocity_params_x
y_vel = mc.velocity_params_y
if x_vel:
print(f" X-Axis: max_vel={x_vel['max_velocity']:.2f}mm/s, accel={x_vel['acceleration']:.2f}mm/s²")
if y_vel:
print(f" Y-Axis: max_vel={y_vel['max_velocity']:.2f}mm/s, accel={y_vel['acceleration']:.2f}mm/s²")
def test_bay_and_channel_status(mc: MotionController) -> dict:
"""
Test bay occupancy and channel enable states.
Returns dict with diagnostic info about each axis.
"""
print("\n" + "="*70)
print("BAY AND CHANNEL DIAGNOSTICS")
print("="*70)
results = {'x': {}, 'y': {}}
# Query bay status using MGMSG_RACK_REQ_BAYUSED (0x0060)
print("\n--- Querying Bay Status (RACK_REQ_BAYUSED 0x0060) ---")
print("Sending to controller (0x11)...")
# Send bay request to controller
mc.send_command(0x0060, param1=0x00, param2=0x00, dest=0x11, source=0x01)
time.sleep(0.2)
# Check for response in queue
msgs = mc.get_all_messages()
bay_response = None
for msg in msgs:
print(f" Received: msg_id=0x{msg.msg_id:04X}, source=0x{msg.source:02X}, "
f"param1=0x{msg.param1:02X}, param2=0x{msg.param2:02X}, data={msg.data.hex() if msg.data else 'none'}")
if msg.msg_id == 0x0061: # RACK_GET_BAYUSED
bay_response = msg
if bay_response:
# param1 contains bay_ident (which bay), param2 contains state
print(f"\nBay status response: bay={bay_response.param1}, state=0x{bay_response.param2:02X}")
else:
print(" No RACK_GET_BAYUSED response received")
# Check channel enable state for X-axis (0x21)
print("\n--- X-Axis (Bay 1 / dest=0x21) Channel Status ---")
print(f" Destination address: 0x{mc.DEST_X_AXIS:02X}")
try:
x_enabled = mc.get_channel_enable_state(mc.DEST_X_AXIS, timeout=2.0)
print(f" Channel enabled: {x_enabled}")
results['x']['enabled'] = x_enabled
except Exception as e:
print(f" Error querying channel state: {e}")
results['x']['enabled'] = None
# Get X position to verify communication
x_pos = mc.get_position(mc.DEST_X_AXIS, timeout=2.0)
print(f" Current position: {x_pos:.4f}mm" if x_pos is not None else " Position query failed!")
results['x']['position'] = x_pos
results['x']['communicating'] = x_pos is not None
# Check channel enable state for Y-axis (0x22)
print("\n--- Y-Axis (Bay 2 / dest=0x22) Channel Status ---")
print(f" Destination address: 0x{mc.DEST_Y_AXIS:02X}")
try:
y_enabled = mc.get_channel_enable_state(mc.DEST_Y_AXIS, timeout=2.0)
print(f" Channel enabled: {y_enabled}")
results['y']['enabled'] = y_enabled
except Exception as e:
print(f" Error querying channel state: {e}")
results['y']['enabled'] = None
# Get Y position to verify communication
y_pos = mc.get_position(mc.DEST_Y_AXIS, timeout=2.0)
print(f" Current position: {y_pos:.4f}mm" if y_pos is not None else " Position query failed!")
results['y']['position'] = y_pos
results['y']['communicating'] = y_pos is not None
return results
def test_y_axis_move_verbose(mc: MotionController) -> bool:
"""
Test Y-axis movement using the new wait_until_settled() method.
Sends a small Y-axis move and monitors progress.
"""
print("\n" + "="*70)
print("Y-AXIS VERBOSE MOVE TEST")
print("="*70)
print("Using wait_until_settled() for motion detection")
# Get current Y position
mc.poll_positions()
time.sleep(0.1)
start_y = mc.position_y
if start_y is None:
print("ERROR: Cannot get Y position")
return False
print(f"\nCurrent Y position: {start_y:.4f}mm")
# Target: move 5mm in Y
target_y = start_y + 5.0
print(f"Target Y position: {target_y:.4f}mm")
# Issue move command using move_to_fast (which tracks pending moves)
print("\n--- Issuing Y-axis move via move_to_fast() ---")
mc.move_to_fast(y=target_y)
# Show pending moves
pending = mc.get_pending_targets()
print(f" Pending moves: {pending}")
print(f" is_move_pending(): {mc.is_move_pending()}")
# Monitor using poll_until_idle
print("\n--- Monitoring Y-axis with poll_until_idle() ---")
print(f"{'Time':>6} {'Y_pos':>10} {'Y_err':>10} {'Pending'}")
print("-" * 45)
start_time = time.time()
movement_detected = False
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
elapsed = time.time() - start_time
curr_y = mc.position_y
if curr_y is not None:
y_err = curr_y - target_y
pending = "Y" if mc.is_move_pending() else "-"
print(f"{elapsed:>5.2f}s {curr_y:>10.4f} {y_err:>+10.4f} {pending:>8}")
if abs(curr_y - start_y) > 0.01:
movement_detected = True
if elapsed > 10.0:
print("\n** TIMEOUT after 10s **")
mc.clear_pending_moves()
break
# Final status
elapsed = time.time() - start_time
final_y = mc.position_y
print(f"\n--- Y-Axis Move Summary ---")
print(f"Start position: {start_y:.4f}mm")
print(f"Target position: {target_y:.4f}mm")
print(f"Final position: {final_y:.4f}mm" if final_y else "Final position: unknown")
print(f"Move time: {elapsed:.3f}s")
if final_y is not None:
distance_moved = abs(final_y - start_y)
print(f"Distance moved: {distance_moved:.4f}mm")
if distance_moved < 0.01:
print("\n** FAIL: Y-axis did not move at all! **")
print("Possible causes:")
print(" 1. Y-axis channel is disabled")
print(" 2. Y-axis motor driver issue")
print(" 3. Command not reaching Y-axis bay")
print(" 4. Mechanical obstruction")
return False
elif abs(final_y - target_y) < 0.05:
print("\n** PASS: Y-axis moved to target **")
return True
else:
print(f"\n** PARTIAL: Y-axis moved but not to target **")
return False
return movement_detected
def test_enable_and_move_y(mc: MotionController) -> bool:
"""
Explicitly enable Y-axis channel and attempt movement.
"""
print("\n" + "="*70)
print("ENABLE Y-AXIS AND MOVE TEST")
print("="*70)
# First, explicitly enable Y-axis channel
print("\n--- Enabling Y-axis channel (MOD_SET_CHANENABLESTATE) ---")
print(f" Sending to dest=0x{mc.DEST_Y_AXIS:02X}, enable=True")
try:
mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True)
time.sleep(0.2)
print(" Enable command sent")
except Exception as e:
print(f" Error sending enable: {e}")
# Verify it's enabled
try:
y_enabled = mc.get_channel_enable_state(mc.DEST_Y_AXIS, timeout=2.0)
print(f" Channel enabled state: {y_enabled}")
except Exception as e:
print(f" Error checking state: {e}")
# Request fresh status
mc.request_status_update(mc.DEST_Y_AXIS)
time.sleep(0.2)
y_bits = mc.status_bits_y
print(f" Status bits: {format_status_bits(y_bits)}")
# Now try the verbose move test
return test_y_axis_move_verbose(mc)
def main():
print("="*70)
print("BBD202 Stage Diagnostic Test")
print("="*70)
mc = None
try:
# Connect to controller
print("\nConnecting to BBD202 controller...")
mc = MotionController()
mc.connect(enable_updates=True)
print("Connected!")
# Print diagnostic info
print_diagnostic_info(mc)
# Run bay and channel diagnostics
channel_results = test_bay_and_channel_status(mc)
# Run homing status test
homed = test_homing_status(mc)
if not homed:
print("\n" + "-"*70)
response = input("Axes not homed. Home now? (y/n): ").strip().lower()
if response == 'y':
print("\nHoming X-axis...")
x_result = mc.home_x_axis(timeout=30.0)
print(f"X-axis homing: {'SUCCESS' if x_result else 'FAILED'}")
print("\nHoming Y-axis...")
y_result = mc.home_y_axis(timeout=30.0)
print(f"Y-axis homing: {'SUCCESS' if y_result else 'FAILED'}")
if not (x_result and y_result):
print("\nHoming failed. Cannot continue with movement test.")
return 1
# Re-check homing status
test_homing_status(mc)
else:
print("\nSkipping movement tests (axes not homed)")
return 1
# Run Y-axis verbose test first to diagnose the issue
y_axis_ok = test_enable_and_move_y(mc)
# If Y-axis failed, skip the square pattern
if not y_axis_ok:
print("\n** Y-axis movement failed - skipping square pattern test **")
square_ok = False
else:
# Run square pattern test
square_ok = test_square_pattern(mc, side_length=15.0)
# Final summary
print("\n" + "="*70)
print("DIAGNOSTIC SUMMARY")
print("="*70)
print(f"X-Axis Channel: {'OK' if channel_results['x'].get('communicating') else 'FAIL'}")
print(f"Y-Axis Channel: {'OK' if channel_results['y'].get('communicating') else 'FAIL'}")
print(f"Homing Status: {'PASS' if homed else 'FAIL'}")
print(f"Y-Axis Move: {'PASS' if y_axis_ok else 'FAIL'}")
print(f"Square Pattern: {'PASS' if square_ok else 'FAIL/SKIPPED'}")
if homed and y_axis_ok and square_ok:
print("\nAll tests PASSED")
return 0
else:
print("\nSome tests FAILED - see details above")
return 1
except KeyboardInterrupt:
print("\n\nTest interrupted by user")
return 1
except Exception as e:
print(f"\nError during test: {e}")
import traceback
traceback.print_exc()
return 1
finally:
if mc is not None:
print("\nDisconnecting from controller...")
mc.disconnect()
print("Disconnected")
if __name__ == "__main__":
sys.exit(main())
+266
View File
@@ -0,0 +1,266 @@
"""
BBD202 Snake Test
Quick test to verify the BBD202 stage driver with a snake scan pattern.
Scans a 20mm x 20mm area with 2mm row spacing in a back-and-forth pattern.
"""
import sys
import time
from hardware.bbd202 import MotionController, MotorStatusBits
def snake_test(mc: MotionController, width: float = 20.0, height: float = 20.0, row_spacing: float = 2.0) -> bool:
"""
Execute a snake scan pattern.
Args:
mc: MotionController instance
width: Scan width in mm (X direction)
height: Scan height in mm (Y direction)
row_spacing: Distance between rows in mm
Pattern:
Start (0,0) -> Move +X to (width, 0) -> Move +Y by row_spacing ->
Move -X to (0, row_spacing) -> Move +Y by row_spacing ->
... repeat until height is covered
"""
print("\n" + "="*70)
print(f"SNAKE TEST: {width}mm x {height}mm area, {row_spacing}mm row spacing")
print("="*70)
# Get starting position
mc.poll_positions()
time.sleep(0.1)
start_x = mc.position_x
start_y = mc.position_y
if start_x is None or start_y is None:
print("ERROR: Could not get initial position")
return False
print(f"\nStarting position: X={start_x:.4f}mm, Y={start_y:.4f}mm")
# Calculate number of rows
num_rows = int(height / row_spacing) + 1
print(f"Number of rows: {num_rows}")
# Generate snake path waypoints
waypoints = []
for row in range(num_rows):
y_pos = start_y + row * row_spacing
if row % 2 == 0:
# Even row: move left to right
waypoints.append((start_x + width, y_pos, f"Row {row+1}/{num_rows} (+X)"))
else:
# Odd row: move right to left
waypoints.append((start_x, y_pos, f"Row {row+1}/{num_rows} (-X)"))
print(f"Total waypoints: {len(waypoints)}")
all_passed = True
total_distance = 0.0
total_time = 0.0
# Execute the snake pattern
for i, (target_x, target_y, description) in enumerate(waypoints):
print(f"\n--- Move {i+1}/{len(waypoints)}: {description} ---")
print(f"Target: X={target_x:.4f}mm, Y={target_y:.4f}mm")
# Record start time and position
move_start = time.time()
mc.poll_positions()
time.sleep(0.02)
curr_x = mc.position_x
curr_y = mc.position_y
if curr_x is not None and curr_y is not None:
distance = ((target_x - curr_x)**2 + (target_y - curr_y)**2)**0.5
total_distance += distance
# Clear any previous error
mc.clear_last_error()
# Issue move command
mc.move_to_fast(x=target_x, y=target_y)
# Wait for move to complete with periodic progress updates
sample_count = 0
max_err = 0.0
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
elapsed = time.time() - move_start
curr_x = mc.position_x
curr_y = mc.position_y
if curr_x is not None and curr_y is not None:
x_err = curr_x - target_x
y_err = curr_y - target_y
total_err = (x_err**2 + y_err**2)**0.5
max_err = max(max_err, total_err)
# Print every 10th sample
if sample_count % 10 == 0:
print(f" {elapsed:>5.2f}s X={curr_x:>8.4f} Y={curr_y:>8.4f} err={total_err*1000:>6.1f}um", end='\r')
sample_count += 1
# Timeout check
if elapsed > 30.0:
print(f"\n** TIMEOUT after 30s - stage may be hung!")
all_passed = False
mc.clear_pending_moves()
break
# Move complete
elapsed = time.time() - move_start
total_time += elapsed
mc.poll_positions()
time.sleep(0.02)
final_x = mc.position_x
final_y = mc.position_y
if final_x is not None and final_y is not None:
x_err = final_x - target_x
y_err = final_y - target_y
total_err = (x_err**2 + y_err**2)**0.5
print(f" {elapsed:>5.2f}s X={final_x:>8.4f} Y={final_y:>8.4f} err={total_err*1000:>6.1f}um ** DONE **")
if total_err > 0.05: # 50um tolerance
print(f" ** WARNING: Position error exceeds 50um: {total_err*1000:.1f}um")
all_passed = False
# Check for errors after move
err = mc.check_for_errors()
if err:
print(f" ** Error after move: {err}")
all_passed = False
last_err = mc.last_error
if last_err:
print(f" ** Last error: {last_err}")
all_passed = False
# Return to start position
print(f"\n--- Returning to start position ---")
move_start = time.time()
mc.move_to_fast(x=start_x, y=start_y)
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
if time.time() - move_start > 30.0:
print("** TIMEOUT returning to start")
mc.clear_pending_moves()
break
elapsed = time.time() - move_start
total_time += elapsed
# Final summary
print(f"\n{'='*70}")
print("SNAKE TEST SUMMARY")
print(f"{'='*70}")
mc.poll_positions()
time.sleep(0.1)
final_x = mc.position_x
final_y = mc.position_y
if final_x is not None and final_y is not None:
return_x_err = abs(final_x - start_x)
return_y_err = abs(final_y - start_y)
return_err = (return_x_err**2 + return_y_err**2)**0.5
print(f"Start position: X={start_x:.4f}mm, Y={start_y:.4f}mm")
print(f"Final position: X={final_x:.4f}mm, Y={final_y:.4f}mm")
print(f"Return error: {return_err*1000:.1f}um (X={return_x_err*1000:.1f}um, Y={return_y_err*1000:.1f}um)")
print(f"Total distance: {total_distance:.2f}mm")
print(f"Total time: {total_time:.2f}s")
print(f"Average speed: {total_distance/total_time:.2f}mm/s")
print(f"Waypoints: {len(waypoints)}")
if return_err > 0.05: # 50um tolerance
print(f"\n** FAIL: Did not return to start position (error: {return_err*1000:.1f}um > 50um)")
all_passed = False
if all_passed:
print(f"\n** PASS: Snake test completed successfully **")
else:
print(f"\n** FAIL: Issues detected during snake test **")
return all_passed
def main():
print("="*70)
print("BBD202 Snake Test")
print("="*70)
mc = None
try:
# Connect to controller
print("\nConnecting to BBD202 controller...")
mc = MotionController()
mc.connect(enable_updates=True)
print("Connected!")
# Brief hardware info
try:
hw_info = mc.get_hw_info()
print(f"\nHardware: {hw_info['model']} (S/N: {hw_info['serial_number']})")
print(f"Firmware: {hw_info['firmware_version']}")
except Exception as e:
print(f"Could not get hardware info: {e}")
# Check homing status
print("\n--- Checking Homing Status ---")
mc.request_status_update(mc.DEST_X_AXIS)
mc.request_status_update(mc.DEST_Y_AXIS)
time.sleep(0.2)
mc.poll_status()
time.sleep(0.1)
x_homed = mc.is_homed_x
y_homed = mc.is_homed_y
print(f"X-axis homed: {x_homed}")
print(f"Y-axis homed: {y_homed}")
if not (x_homed and y_homed):
print("\n** Axes not homed - will home now **")
print("\nHoming X-axis...")
x_result = mc.home_x_axis(timeout=30.0)
print(f"X-axis: {'SUCCESS' if x_result else 'FAILED'}")
print("\nHoming Y-axis...")
y_result = mc.home_y_axis(timeout=30.0)
print(f"Y-axis: {'SUCCESS' if y_result else 'FAILED'}")
if not (x_result and y_result):
print("\n** Homing failed. Cannot continue. **")
return 1
# Run the snake test
success = snake_test(mc, width=20.0, height=20.0, row_spacing=2.0)
return 0 if success else 1
except KeyboardInterrupt:
print("\n\nTest interrupted by user")
return 1
except Exception as e:
print(f"\n** ERROR: {e}")
import traceback
traceback.print_exc()
return 1
finally:
if mc is not None:
print("\nDisconnecting from controller...")
mc.disconnect()
print("Disconnected")
if __name__ == "__main__":
sys.exit(main())
+300
View File
@@ -0,0 +1,300 @@
"""
BBD202 Snake Scan Test - 20x20mm Area
Scans a 20mm x 20mm area centered at X=55, Y=37.5 in a snake pattern.
Row spacing: 0.5mm
Movement order: X+, Y+, X-, Y+ (snake pattern)
"""
import sys
import time
import argparse
from hardware.bbd202 import MotionController, MotorStatusBits
# Scan parameters
CENTER_X = 55.0 # mm
CENTER_Y = 37.5 # mm
SCAN_WIDTH = 20.0 # mm (X direction)
SCAN_HEIGHT = 20.0 # mm (Y direction)
ROW_SPACING = 0.5 # mm (Y step between rows)
# Calculated bounds
START_X = CENTER_X - SCAN_WIDTH / 2 # 45.0 mm
END_X = CENTER_X + SCAN_WIDTH / 2 # 65.0 mm
START_Y = CENTER_Y - SCAN_HEIGHT / 2 # 27.5 mm
END_Y = CENTER_Y + SCAN_HEIGHT / 2 # 47.5 mm
def snake_scan(mc: MotionController) -> bool:
"""
Execute a snake scan pattern over the 20x20mm area.
Pattern (X+, Y+, X-, Y+):
Row 0: X=45 -> X=65 (X+)
Step: Y += 0.5 (Y+)
Row 1: X=65 -> X=45 (X-)
Step: Y += 0.5 (Y+)
... repeat until Y reaches 47.5mm
"""
print("\n" + "=" * 70)
print("SNAKE SCAN TEST: 20mm x 20mm")
print("=" * 70)
print(f"Center: X={CENTER_X:.1f}mm, Y={CENTER_Y:.1f}mm")
print(f"Bounds: X=[{START_X:.1f}, {END_X:.1f}]mm, Y=[{START_Y:.1f}, {END_Y:.1f}]mm")
print(f"Row spacing: {ROW_SPACING}mm")
print(f"Pattern: X+, Y+, X-, Y+ (snake)")
# Calculate number of rows
num_rows = int(SCAN_HEIGHT / ROW_SPACING) + 1
print(f"Total rows: {num_rows}")
# Stop automatic ACK to prevent interference with move commands
# We'll send manual ACKs after each move completes
mc.stop_status_ack()
print("Using manual ACK mode for scan")
# First, move to the starting position
print(f"\n--- Moving to start position ({START_X:.1f}, {START_Y:.1f}) ---")
mc.move_to_fast(x=START_X, y=START_Y)
move_start = time.time()
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
if time.time() - move_start > 30.0:
print("** TIMEOUT moving to start position!")
return False
# Verify starting position
mc.poll_positions()
time.sleep(0.1)
curr_x = mc.position_x
curr_y = mc.position_y
print(f"At start: X={curr_x:.4f}mm, Y={curr_y:.4f}mm")
# Generate snake path waypoints
waypoints = []
for row in range(num_rows):
y_pos = START_Y + row * ROW_SPACING
if row % 2 == 0:
# Even row: move X+ (left to right)
waypoints.append((END_X, y_pos, f"Row {row + 1}/{num_rows} X+ (Y={y_pos:.1f})"))
else:
# Odd row: move X- (right to left)
waypoints.append((START_X, y_pos, f"Row {row + 1}/{num_rows} X- (Y={y_pos:.1f})"))
print(f"\nTotal waypoints: {len(waypoints)}")
print("-" * 70)
all_passed = True
total_distance = 0.0
total_time = 0.0
scan_start_time = time.time()
# Execute the snake pattern
for i, (target_x, target_y, description) in enumerate(waypoints):
# Record start position for distance calculation
mc.poll_positions()
time.sleep(0.02)
prev_x = mc.position_x
prev_y = mc.position_y
if prev_x is not None and prev_y is not None:
distance = ((target_x - prev_x) ** 2 + (target_y - prev_y) ** 2) ** 0.5
total_distance += distance
# Clear any previous error
mc.clear_last_error()
# Issue move command
move_start = time.time()
mc.move_to_fast(x=target_x, y=target_y)
# Wait for move to complete
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
elapsed = time.time() - move_start
# Timeout check
if elapsed > 30.0:
print(f"\n** TIMEOUT on {description} after 30s!")
all_passed = False
mc.clear_pending_moves()
break
# Move complete - send ACK to keep controller responsive
mc.ack_status_update()
elapsed = time.time() - move_start
total_time += elapsed
mc.poll_positions()
time.sleep(0.02)
final_x = mc.position_x
final_y = mc.position_y
if final_x is not None and final_y is not None:
x_err = final_x - target_x
y_err = final_y - target_y
total_err = (x_err ** 2 + y_err ** 2) ** 0.5
# Print progress (compact format)
status = "OK" if total_err < 0.05 else "ERR"
print(f" {description:35} -> X={final_x:7.3f} Y={final_y:7.3f} err={total_err * 1000:5.1f}um [{status}]")
if total_err > 0.05: # 50um tolerance
all_passed = False
# Check for errors after move
err = mc.check_for_errors()
if err:
print(f" ** Error: {err}")
all_passed = False
last_err = mc.last_error
if last_err:
print(f" ** Last error: {last_err}")
all_passed = False
scan_elapsed = time.time() - scan_start_time
# Return to start position
print(f"\n--- Returning to start position ({START_X:.1f}, {START_Y:.1f}) ---")
move_start = time.time()
mc.move_to_fast(x=START_X, y=START_Y)
while not mc.poll_until_idle(tolerance=0.005, timeout=0.05):
if time.time() - move_start > 30.0:
print("** TIMEOUT returning to start")
mc.clear_pending_moves()
break
# Final summary
print(f"\n{'=' * 70}")
print("SNAKE SCAN SUMMARY")
print(f"{'=' * 70}")
mc.poll_positions()
time.sleep(0.1)
final_x = mc.position_x
final_y = mc.position_y
if final_x is not None and final_y is not None:
return_x_err = abs(final_x - START_X)
return_y_err = abs(final_y - START_Y)
return_err = (return_x_err ** 2 + return_y_err ** 2) ** 0.5
print(f"Scan area: {SCAN_WIDTH:.1f}mm x {SCAN_HEIGHT:.1f}mm")
print(f"Center: X={CENTER_X:.1f}mm, Y={CENTER_Y:.1f}mm")
print(f"Row spacing: {ROW_SPACING}mm")
print(f"Rows completed: {num_rows}")
print(f"Total distance: {total_distance:.2f}mm")
print(f"Scan time: {scan_elapsed:.2f}s")
print(f"Average speed: {total_distance / scan_elapsed:.2f}mm/s")
print(f"Return error: {return_err * 1000:.1f}um (X={return_x_err * 1000:.1f}um, Y={return_y_err * 1000:.1f}um)")
if return_err > 0.05:
print(f"\n** FAIL: Return position error exceeds 50um")
all_passed = False
# Restart automatic ACK
mc.start_status_ack()
if all_passed:
print(f"\n** PASS: Snake scan completed successfully **")
else:
print(f"\n** FAIL: Issues detected during snake scan **")
return all_passed
def main():
parser = argparse.ArgumentParser(description="BBD202 Snake Scan Test - 20x20mm Area")
parser.add_argument("--auto-home", action="store_true",
help="Automatically home axes if not homed (no prompt)")
args = parser.parse_args()
print("=" * 70)
print("BBD202 Snake Scan Test - 20x20mm Area")
print("=" * 70)
mc = None
try:
# Connect to controller
print("\nConnecting to BBD202 controller...")
mc = MotionController()
mc.connect(enable_updates=True)
print("Connected!")
# Brief hardware info
try:
hw_info = mc.get_hw_info()
print(f"\nHardware: {hw_info['model']} (S/N: {hw_info['serial_number']})")
print(f"Firmware: {hw_info['firmware_version']}")
except Exception as e:
print(f"Could not get hardware info: {e}")
# Check homing status
print("\n--- Checking Homing Status ---")
mc.request_status_update(mc.DEST_X_AXIS)
mc.request_status_update(mc.DEST_Y_AXIS)
time.sleep(0.2)
mc.poll_status()
time.sleep(0.1)
x_homed = mc.is_homed_x
y_homed = mc.is_homed_y
print(f"X-axis homed: {x_homed}")
print(f"Y-axis homed: {y_homed}")
if not (x_homed and y_homed):
print("\n** Axes not homed! **")
# Check if we should auto-home or prompt
should_home = args.auto_home
if not should_home:
try:
response = input("Home axes now? (y/n): ").strip().lower()
should_home = response == 'y'
except EOFError:
print("Non-interactive mode detected. Use --auto-home flag.")
return 1
if should_home:
print("\nHoming X-axis...")
x_result = mc.home_x_axis(timeout=30.0)
print(f"X-axis: {'SUCCESS' if x_result else 'FAILED'}")
print("\nHoming Y-axis...")
y_result = mc.home_y_axis(timeout=30.0)
print(f"Y-axis: {'SUCCESS' if y_result else 'FAILED'}")
if not (x_result and y_result):
print("\n** Homing failed. Cannot continue. **")
return 1
else:
print("\nCannot run snake scan without homing. Exiting.")
return 1
# Run the snake scan
success = snake_scan(mc)
return 0 if success else 1
except KeyboardInterrupt:
print("\n\nTest interrupted by user")
return 1
except Exception as e:
print(f"\n** ERROR: {e}")
import traceback
traceback.print_exc()
return 1
finally:
if mc is not None:
print("\nDisconnecting from controller...")
mc.disconnect()
print("Disconnected")
if __name__ == "__main__":
sys.exit(main())
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Test script for camera integration with the scan wizard.
Tests the camera driver and integration with the UI.
"""
import sys
from PyQt6 import QtWidgets
def test_camera_import():
"""Test that the camera driver can be imported"""
print("Testing camera driver import...")
try:
from uc480_camera import UC480Camera, CameraStreamThread
print("✓ Camera driver imported successfully")
return True
except ImportError as e:
print(f"✗ Failed to import camera driver: {e}")
return False
def test_camera_class():
"""Test that the camera class can be instantiated"""
print("\nTesting camera class instantiation...")
try:
from uc480_camera import UC480Camera
camera = UC480Camera(camera_id=0)
print("✓ Camera class instantiated successfully")
print(f" Camera handle: {camera.h_cam}")
print(f" Initialized: {camera.is_initialized}")
return True
except Exception as e:
print(f"✗ Failed to instantiate camera class: {e}")
return False
def test_scanengine_import():
"""Test that the scanengine app can be imported with camera integration"""
print("\nTesting scanengine app import...")
try:
from scanengine_app import NewScanWizard, MainLauncher
print("✓ Scanengine app imported successfully")
return True
except ImportError as e:
print(f"✗ Failed to import scanengine app: {e}")
return False
def test_wizard_with_camera():
"""Test that the wizard can be created with camera integration"""
print("\nTesting wizard with camera integration...")
try:
app = QtWidgets.QApplication(sys.argv)
from scanengine_app import NewScanWizard
wizard = NewScanWizard()
print("✓ Wizard created successfully")
print(f" Camera object: {wizard.camera}")
print(f" Camera stream thread: {wizard.camera_stream_thread}")
print(f" CCD scene: {wizard.ccd_scene}")
# Check if camera methods exist
assert hasattr(wizard, 'initialize_camera'), "Missing initialize_camera method"
assert hasattr(wizard, 'start_camera_stream'), "Missing start_camera_stream method"
assert hasattr(wizard, 'stop_camera_stream'), "Missing stop_camera_stream method"
assert hasattr(wizard, 'cleanup_camera'), "Missing cleanup_camera method"
print("✓ All camera methods present")
# Test page change triggers
print("\nTesting page change behavior...")
current_page = wizard.stackedWidget.currentIndex()
print(f" Current page: {current_page}")
# Simulate page navigation to focus page (index 1)
print(" Navigating to focus page (index 1)...")
wizard.stackedWidget.setCurrentIndex(1)
print(f" Current page after navigation: {wizard.stackedWidget.currentIndex()}")
# Navigate back to first page
print(" Navigating back to page 0...")
wizard.stackedWidget.setCurrentIndex(0)
print(f" Current page after navigation: {wizard.stackedWidget.currentIndex()}")
# Cleanup
wizard.cleanup_camera()
print("✓ Camera cleanup successful")
return True
except Exception as e:
print(f"✗ Failed to test wizard with camera: {e}")
import traceback
traceback.print_exc()
return False
def main():
"""Run all tests"""
print("=" * 60)
print("Camera Integration Test Suite")
print("=" * 60)
results = []
# Run tests
results.append(("Camera Import", test_camera_import()))
results.append(("Camera Class", test_camera_class()))
results.append(("Scanengine Import", test_scanengine_import()))
results.append(("Wizard Integration", test_wizard_with_camera()))
# Print summary
print("\n" + "=" * 60)
print("Test Summary")
print("=" * 60)
for test_name, passed in results:
status = "PASS" if passed else "FAIL"
symbol = "✓" if passed else "✗"
print(f"{symbol} {test_name}: {status}")
all_passed = all(result[1] for result in results)
print("\n" + "=" * 60)
if all_passed:
print("All tests passed!")
else:
print("Some tests failed.")
print("=" * 60)
return 0 if all_passed else 1
if __name__ == "__main__":
sys.exit(main())
+196
View File
@@ -0,0 +1,196 @@
#!/opt/srasenv/bin/python3
"""
Test script for Genesis laser serial communication.
Tests basic connectivity and I2C protocol without GUI.
"""
import sys
import time
import serial
# Constants
NXP_START_BYTE = 0x53
NXP_STOP_BYTE = 0x50
ADDR_PCA9555_PS_GLUE_OUT = 0x4a
ADDR_ADS7828 = 0x90
CHAN_CURRENT_ACTUAL = 0x84
def test_serial_connection(port_name="/dev/ttyUSB1", baudrate=9600):
"""Test basic serial connection."""
print(f"Testing serial connection to {port_name} @ {baudrate}...")
try:
port = serial.Serial(
port=port_name,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1.0
)
print("✓ Serial port opened successfully")
return port
except Exception as e:
print(f"✗ Failed to open serial port: {e}")
return None
def nxp_write(port, i2c_addr_write, cmd, data, data_len):
"""Build and send NXP I2C write packet."""
# Determine command length
if cmd <= 0xFF:
cmd_bytes = bytes([cmd])
else:
cmd_bytes = cmd.to_bytes(2, 'big')
# Convert data to bytes (big-endian)
data_bytes = data.to_bytes(data_len, 'big')
# Calculate total length
total_len = len(cmd_bytes) + len(data_bytes)
# Build packet: [0x53][addr][len][cmd...][data...][0x50]
packet = bytes([
NXP_START_BYTE,
i2c_addr_write,
total_len
]) + cmd_bytes + data_bytes + bytes([NXP_STOP_BYTE])
print(f" TX: {packet.hex(' ')}")
port.write(packet)
return True
def nxp_read(port, i2c_addr_write, cmd, cmd_len, data_len):
"""Build and send NXP I2C read packet."""
# Convert command to bytes
if cmd_len == 1:
cmd_bytes = bytes([cmd])
else:
cmd_bytes = cmd.to_bytes(2, 'big')
# Build packet: [0x53][addr][cmd_len][cmd...][0x53][addr|0x01][data_len][0x50]
packet = bytes([
NXP_START_BYTE,
i2c_addr_write,
cmd_len
]) + cmd_bytes + bytes([
NXP_START_BYTE,
i2c_addr_write | 0x01, # Read address
data_len,
NXP_STOP_BYTE
])
print(f" TX: {packet.hex(' ')}")
port.write(packet)
time.sleep(0.1)
# Read response
response = port.read(data_len)
if response:
print(f" RX: {response.hex(' ')} ({len(response)} bytes)")
return int.from_bytes(response, 'big')
else:
print(f" RX: (no data)")
return None
def test_read_ps_glue_out(port):
"""Test reading PS glue output port."""
print("\nTest 1: Reading PS glue output port (0x4a, reg 0x02)...")
value = nxp_read(port, ADDR_PCA9555_PS_GLUE_OUT, 0x02, 1, 1)
if value is not None:
print(f"✓ PS Glue Out status: 0x{value:02x}")
print(f" Shutter: {'OPEN' if value & 0x01 else 'CLOSED'}")
print(f" Current Mode: {'ON' if value & 0x04 else 'OFF'}")
print(f" Remote Enable: {'ON' if value & 0x08 else 'OFF'}")
print(f" Analog Enable: {'ON' if value & 0x10 else 'OFF'}")
print(f" Keyswitch: {'ON' if value & 0x20 else 'OFF'}")
return True
else:
print("✗ Failed to read PS glue out")
return False
def test_read_current_adc(port):
"""Test reading current ADC."""
print("\nTest 2: Reading current ADC (ADS7828, channel 0x84)...")
value = nxp_read(port, ADDR_ADS7828, CHAN_CURRENT_ACTUAL, 1, 2)
if value is not None:
print(f"✓ Current ADC raw value: 0x{value:04x} ({value})")
scaled = value * 0.000244140625
print(f" Scaled value: {scaled:.6f}")
return True
else:
print("✗ Failed to read current ADC")
return False
def test_write_current_zero(port):
"""Test setting current to zero."""
print("\nTest 3: Setting current to 0 (X9119 @ 0x52, cmd 0xa0)...")
success = nxp_write(port, 0x52, 0xa0, 0, 2)
if success:
print("✓ Current set to 0 command sent")
return True
else:
print("✗ Failed to set current")
return False
def test_read_status_bits(port):
"""Test reading all status bits from PS glue output."""
print("\nTest 4: Reading all control status bits...")
# Read current state
current_state = nxp_read(port, ADDR_PCA9555_PS_GLUE_OUT, 0x02, 1, 1)
if current_state is None:
print("✗ Failed to read current state")
return False
print(f" Current state: 0x{current_state:02x}")
print(f" Note: Shutter bit status: {'SET' if current_state & 0x01 else 'CLEAR'} (manual shutter - not controlled)")
print("✓ Successfully read all status bits")
return True
def main():
"""Run all tests."""
print("=" * 60)
print("Genesis SLM MX 532 - Serial Communication Test")
print("=" * 60)
# Open serial port
port = test_serial_connection()
if not port:
print("\nTest FAILED: Cannot open serial port")
return 1
try:
# Run tests
tests_passed = 0
tests_total = 4
if test_read_ps_glue_out(port):
tests_passed += 1
if test_read_current_adc(port):
tests_passed += 1
if test_write_current_zero(port):
tests_passed += 1
if test_read_status_bits(port):
tests_passed += 1
# Summary
print("\n" + "=" * 60)
print(f"Test Summary: {tests_passed}/{tests_total} tests passed")
print("=" * 60)
if tests_passed == tests_total:
print("✓ All tests PASSED - Communication working!")
print("\nYou can now run the GUI application:")
print(" ./genesis_laser_gui.py")
return 0
else:
print("✗ Some tests FAILED - Check connections")
return 1
finally:
port.close()
print("\nSerial port closed.")
if __name__ == '__main__':
sys.exit(main())
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
Genesis Laser Protocol Test Script
===================================
Simple command-line script to test NXP I2C-over-serial communication
with the Genesis SLM MX 532 laser.
Usage:
python test_genesis_protocol.py [port] [baudrate]
Example:
python test_genesis_protocol.py /dev/ttyUSB0 9600
"""
import sys
import time
import serial
from typing import Optional
class NXPProtocolTester:
"""Simple NXP I2C protocol tester"""
NXP_START = 0x53
NXP_STOP = 0x50
def __init__(self, port: str = "/dev/ttyUSB0", baudrate: int = 9600):
self.port = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1.0
)
time.sleep(0.1)
print(f"Connected to {port} at {baudrate} baud")
def send_packet(self, i2c_addr: int, cmd: bytes, data: bytes = b''):
"""Send NXP I2C write packet"""
if not isinstance(cmd, bytes):
cmd = bytes([cmd])
if not isinstance(data, bytes):
data = bytes(data)
length = len(cmd) + len(data)
packet = bytes([self.NXP_START, i2c_addr, length]) + cmd + data + bytes([self.NXP_STOP])
print(f"TX: {' '.join(f'{b:02x}' for b in packet)}")
self.port.write(packet)
def read_packet(self, i2c_addr_write: int, cmd: bytes, cmd_len: int, data_len: int) -> Optional[bytes]:
"""Send NXP I2C read packet and return data"""
if not isinstance(cmd, bytes):
cmd = bytes([cmd])
i2c_addr_read = i2c_addr_write | 0x01
packet_write = bytes([self.NXP_START, i2c_addr_write, cmd_len]) + cmd
packet_read = bytes([self.NXP_START, i2c_addr_read, data_len, self.NXP_STOP])
packet = packet_write + packet_read
print(f"TX: {' '.join(f'{b:02x}' for b in packet)}")
self.port.write(packet)
time.sleep(0.05)
response = self.port.read(data_len)
if response:
print(f"RX: {' '.join(f'{b:02x}' for b in response)}")
return response
else:
print("RX: (no response)")
return None
def test_x9119_current(self, value: int = 100):
"""Test X9119 current control"""
print(f"\n=== Testing X9119 Current Control (value={value}) ===")
# X9119 current control at 0x52
# Command 0xa0, 2 bytes data (10-bit value)
value = max(0, min(1023, value))
msb = (value >> 8) & 0x03
lsb = value & 0xFF
self.send_packet(0x52, bytes([0xa0]), bytes([msb, lsb]))
print("Current command sent")
def test_pca9555_shutter(self, state: bool):
"""Test PCA9555 shutter control"""
print(f"\n=== Testing PCA9555 Shutter Control (state={'OPEN' if state else 'CLOSED'}) ===")
# Read current output port 0 value
print("Reading current port value...")
current = self.read_packet(0x4a, bytes([0x02]), 1, 1)
if current and len(current) == 1:
current_value = current[0]
print(f"Current port value: 0x{current_value:02x}")
# Modify bit 0 (shutter)
if state:
new_value = current_value | 0x01
else:
new_value = current_value & ~0x01
print(f"New port value: 0x{new_value:02x}")
# Write back
self.send_packet(0x4a, bytes([0x02]), bytes([new_value]))
print("Shutter command sent")
else:
print("Failed to read current port value")
def test_ads7828_current_reading(self):
"""Test ADS7828 current reading"""
print(f"\n=== Testing ADS7828 Current Reading ===")
# ADS7828 at 0x90/0x91
# Command byte: 0x80 (single-ended) | (channel << 4) | 0x0c (internal ref)
# Reading channel 0
cmd_byte = 0x80 | (0 << 4) | 0x0c
data = self.read_packet(0x90, bytes([cmd_byte]), 1, 2)
if data and len(data) == 2:
value = (data[0] << 8) | data[1]
value = (value >> 4) & 0x0FFF
print(f"Current reading: {value} counts (0x{value:03x})")
else:
print("Failed to read current")
def test_pca9555_read_ports(self):
"""Test reading all PCA9555 I/O expander ports"""
print(f"\n=== Testing PCA9555 Port Reads ===")
devices = [
(0x4a, "Main DIO (0x14a)"),
(0x48, "PS Glue (0x148)"),
(0x44, "Head DIO (0x144)"),
(0x40, "LDD Control (0x140)"),
]
for addr, name in devices:
print(f"\n{name}:")
for port in range(8):
data = self.read_packet(addr, bytes([port]), 1, 1)
if data and len(data) == 1:
print(f" Register 0x{port:02x}: 0x{data[0]:02x} (0b{data[0]:08b})")
else:
print(f" Register 0x{port:02x}: read failed")
time.sleep(0.05)
def close(self):
"""Close serial port"""
self.port.close()
print("\nConnection closed")
def main():
"""Main test function"""
# Parse command line arguments
port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB0"
baudrate = int(sys.argv[2]) if len(sys.argv) > 2 else 9600
try:
# Create tester
tester = NXPProtocolTester(port, baudrate)
# Menu
while True:
print("\n" + "="*60)
print("Genesis Laser Protocol Test Menu")
print("="*60)
print("1. Test X9119 current control (set to 100)")
print("2. Test PCA9555 shutter OPEN")
print("3. Test PCA9555 shutter CLOSE")
print("4. Test ADS7828 current reading")
print("5. Test read all PCA9555 ports")
print("6. Set current to 0 (safe state)")
print("7. Emergency stop (shutter close + current 0)")
print("8. Custom X9119 current value")
print("0. Exit")
print("="*60)
choice = input("Enter choice: ").strip()
if choice == "1":
tester.test_x9119_current(100)
elif choice == "2":
tester.test_pca9555_shutter(True)
elif choice == "3":
tester.test_pca9555_shutter(False)
elif choice == "4":
tester.test_ads7828_current_reading()
elif choice == "5":
tester.test_pca9555_read_ports()
elif choice == "6":
print("\n=== Setting current to 0 ===")
tester.test_x9119_current(0)
elif choice == "7":
print("\n=== EMERGENCY STOP ===")
tester.test_pca9555_shutter(False)
time.sleep(0.1)
tester.test_x9119_current(0)
print("Emergency stop complete")
elif choice == "8":
try:
value = int(input("Enter current value (0-1023): "))
tester.test_x9119_current(value)
except ValueError:
print("Invalid value")
elif choice == "0":
break
else:
print("Invalid choice")
tester.close()
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Test script for the new rotated AoI bounding box approach.
"""
from decimal import Decimal
from scanning.sc3_scan_model import SC3ScanModel
def test_rotated_aoi():
"""Test the rotated AoI bounding box implementation."""
# Create scan model
model = SC3ScanModel()
# Configure scan parameters
model.x_origin = Decimal('50.0')
model.y_origin = Decimal('35.0')
model.x_delta = Decimal('10.0')
model.y_delta = Decimal('5.0')
model.row_spacing = Decimal('1.0')
model.laser_frequency = Decimal('2000.0')
model.scan_velocity = Decimal('100.0')
model.scan_angles = 4 # 0, 45, 90, 135 degrees
print("Scan Model Configuration:")
print(f" AoI Origin: ({model.x_origin}, {model.y_origin})")
print(f" AoI Size: {model.x_delta} x {model.y_delta}")
print(f" Row Spacing: {model.row_spacing}")
print(f" Optical Origin: ({model.optical_x_origin}, {model.optical_y_origin})")
print(f" Scan Angles: {model.get_angle_list()}")
print(f" Rows Required: {model.rows_required}")
print()
# Verify zero-angle scan (should be horizontal lines)
print("Zero-Angle Scan (first 3 lines):")
for i, coords in enumerate(model.scan_coordinates[:3]):
print(f" Line {i}: ({coords[0]}, {coords[1]}) -> ({coords[2]}, {coords[3]})")
print()
# Verify rotated scans
print("Rotated Scans (first line of each angle):")
for angle_idx, angle_deg in enumerate(model.get_angle_list()):
if angle_idx < len(model.rotated_coordinates):
rotated_scan = model.rotated_coordinates[angle_idx]
if rotated_scan:
coords = rotated_scan[0]
print(f" Angle {angle_deg}°: ({coords[0]:.3f}, {coords[1]:.3f}) -> ({coords[2]:.3f}, {coords[3]:.3f})")
print(f" Number of lines: {len(rotated_scan)}")
print()
# Verify that scans are horizontal (+x direction)
print("Verifying horizontal scan direction:")
for angle_idx, angle_deg in enumerate(model.get_angle_list()):
if angle_idx < len(model.rotated_coordinates):
rotated_scan = model.rotated_coordinates[angle_idx]
all_horizontal = True
for coords in rotated_scan:
# Check if y_start == y_end (horizontal line)
if abs(coords[1] - coords[3]) > 1e-10:
all_horizontal = False
break
status = "✓" if all_horizontal else "✗"
print(f" Angle {angle_deg}°: {status} All lines horizontal")
print()
print("Test completed successfully!")
if __name__ == "__main__":
test_rotated_aoi()
+172
View File
@@ -0,0 +1,172 @@
"""
Test script to examine raw status update packets from the BBD202 controller.
This script:
1. Connects to the controller at a low level
2. Sends HW_START_UPDATEMSGS to enable automatic status updates
3. Captures and displays raw packets to verify the format matches our parsing
"""
import sys
import time
import struct
from hardware.bbd202 import MotionController, MsgId
def hexdump(data: bytes, prefix: str = "") -> str:
"""Format bytes as hex dump."""
hex_str = " ".join(f"{b:02X}" for b in data)
ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in data)
return f"{prefix}{hex_str} |{ascii_str}|"
def decode_status_update(data: bytes) -> dict:
"""Decode a MOT_GET_USTATUSUPDATE message data payload."""
if len(data) < 14:
return {"error": f"Data too short: {len(data)} bytes, expected 14"}
chan_ident = struct.unpack('<H', data[0:2])[0]
position_counts = struct.unpack('<i', data[2:6])[0]
velocity_counts = struct.unpack('<H', data[6:8])[0]
motor_current = struct.unpack('<H', data[8:10])[0]
status_bits = struct.unpack('<I', data[10:14])[0]
# Convert to physical units
ENCODER_COUNTS_PER_MM = 20000
position_mm = position_counts / ENCODER_COUNTS_PER_MM
return {
"chan_ident": chan_ident,
"position_counts": position_counts,
"position_mm": position_mm,
"velocity_counts": velocity_counts,
"motor_current": motor_current,
"status_bits": status_bits,
"status_hex": f"0x{status_bits:08X}"
}
def main():
print("=" * 70)
print("BBD202 Status Update Packet Test")
print("=" * 70)
mc = None
try:
# Connect to controller WITHOUT enabling updates yet
print("\nConnecting to BBD202 controller...")
mc = MotionController()
mc.connect(enable_updates=False) # Don't auto-enable updates
print("Connected!")
# Get hardware info
try:
hw_info = mc.get_hw_info()
print(f"Hardware: {hw_info['model']} (S/N: {hw_info['serial_number']})")
print(f"Firmware: {hw_info['firmware_version']}")
except Exception as e:
print(f"Could not get hardware info: {e}")
# Clear any pending messages
print("\nClearing RX queue...")
msgs = mc.get_all_messages()
print(f"Cleared {len(msgs)} pending messages")
# Now enable status updates
print("\n" + "-" * 70)
print("Sending HW_START_UPDATEMSGS to enable automatic status updates...")
print("-" * 70)
mc.start_update_messages()
# Wait a moment for updates to start arriving
time.sleep(0.5)
# Collect messages for a few seconds
print("\nCollecting status update packets for 3 seconds...")
print("(Looking for MOT_GET_USTATUSUPDATE = 0x0491)")
print()
start_time = time.time()
update_count = 0
other_count = 0
while time.time() - start_time < 3.0:
msg = mc.get_message(timeout=0.1)
if msg:
elapsed = time.time() - start_time
if msg.msg_id == MsgId.MOT_GET_USTATUSUPDATE:
update_count += 1
source_name = "X-axis" if msg.source == 0x21 else "Y-axis" if msg.source == 0x22 else f"0x{msg.source:02X}"
print(f"[{elapsed:5.2f}s] MOT_GET_USTATUSUPDATE from {source_name}")
print(f" Raw ({len(msg.raw)} bytes): {hexdump(msg.raw)}")
print(f" Data ({len(msg.data)} bytes): {hexdump(msg.data)}")
decoded = decode_status_update(msg.data)
if "error" in decoded:
print(f" DECODE ERROR: {decoded['error']}")
else:
print(f" Decoded: chan={decoded['chan_ident']}, "
f"pos={decoded['position_mm']:.4f}mm ({decoded['position_counts']} counts), "
f"vel={decoded['velocity_counts']}, cur={decoded['motor_current']}, "
f"status={decoded['status_hex']}")
print()
else:
other_count += 1
msg_name = MsgId(msg.msg_id).name if msg.msg_id in [m.value for m in MsgId] else f"0x{msg.msg_id:04X}"
print(f"[{elapsed:5.2f}s] Other message: {msg_name} from 0x{msg.source:02X}")
print(f" Raw: {hexdump(msg.raw)}")
print()
print("-" * 70)
print(f"Summary: Received {update_count} status updates, {other_count} other messages")
print("-" * 70)
if update_count == 0:
print("\n*** WARNING: No status updates received! ***")
print("Possible causes:")
print(" 1. HW_START_UPDATEMSGS not being processed")
print(" 2. Controller firmware doesn't support automatic updates")
print(" 3. Updates are being sent but not parsed correctly")
print("\nTrying to manually request a status update...")
# Try requesting status update manually
mc.request_status_update(mc.DEST_X_AXIS)
mc.request_status_update(mc.DEST_Y_AXIS)
time.sleep(0.5)
msgs = mc.get_all_messages()
print(f"\nReceived {len(msgs)} messages after manual request:")
for msg in msgs:
msg_name = MsgId(msg.msg_id).name if msg.msg_id in [m.value for m in MsgId] else f"0x{msg.msg_id:04X}"
print(f" {msg_name} from 0x{msg.source:02X}: {hexdump(msg.raw)}")
# Also check cached positions
print("\n" + "-" * 70)
print("Cached positions in driver:")
print("-" * 70)
print(f" X position: {mc.get_stage_position_x()}")
print(f" Y position: {mc.get_stage_position_y()}")
print(f" X encoder: {mc.encoder_count_x}")
print(f" Y encoder: {mc.encoder_count_y}")
return 0
except KeyboardInterrupt:
print("\n\nTest interrupted by user")
return 1
except Exception as e:
print(f"\n*** ERROR: {e}")
import traceback
traceback.print_exc()
return 1
finally:
if mc is not None:
print("\nDisconnecting from controller...")
mc.disconnect()
print("Disconnected")
if __name__ == "__main__":
sys.exit(main())
+71
View File
@@ -0,0 +1,71 @@
#!/opt/srasenv/bin/python3
"""
Test temperature scaling calculations.
"""
import math
# Constants
ADC_TO_VOLTS = 0.000244140625
STEINHART_A = 0.0011279
STEINHART_B = 0.00023429
STEINHART_C = 8.7298e-8
def test_main_temp_calculation(raw_adc):
"""Test main temperature calculation with different circuit assumptions."""
print(f"\n{'='*60}")
print(f"Testing Main Temperature with RAW ADC = {raw_adc}")
print(f"{'='*60}")
v_thermistor = raw_adc * ADC_TO_VOLTS
print(f"V_thermistor = {v_thermistor:.6f} V")
# Try different circuit configurations
configs = [
(10000, 5.0, "10kΩ series, 5V ref"),
(10000, 3.3, "10kΩ series, 3.3V ref"),
(100000, 5.0, "100kΩ series, 5V ref"),
(10000, 2.5, "10kΩ series, 2.5V ref"),
]
for r_series, vref, desc in configs:
print(f"\n{desc}:")
print(f" R_series = {r_series} Ω, Vref = {vref} V")
if v_thermistor >= vref:
print(f" ERROR: V_thermistor >= Vref")
continue
r_thermistor = r_series * v_thermistor / (vref - v_thermistor)
print(f" R_thermistor = {r_thermistor:.2f} Ω")
if r_thermistor <= 0:
print(f" ERROR: Invalid resistance")
continue
# Steinhart-Hart
ln_r = math.log(r_thermistor)
inv_t = STEINHART_A + STEINHART_B * ln_r + STEINHART_C * (ln_r ** 3)
temp_k = 1.0 / inv_t
temp_c = temp_k - 273.15
print(f" ln(R) = {ln_r:.6f}")
print(f" Temperature = {temp_c:.2f} °C")
def test_current_scaling(raw_adc):
"""Test current scaling."""
print(f"\n{'='*60}")
print(f"Testing Current Scaling with RAW ADC = {raw_adc}")
print(f"{'='*60}")
current = raw_adc * 12.0 * ADC_TO_VOLTS
print(f"Current = {current:.6f} A")
if __name__ == '__main__':
# Test with the reported raw values
test_main_temp_calculation(2)
test_main_temp_calculation(3)
test_current_scaling(0)
test_current_scaling(100)
test_current_scaling(4095)
+828
View File
@@ -0,0 +1,828 @@
#!/usr/bin/env python3
"""
Genesis SLM MX 532 Laser Control Application
=============================================
This application provides comprehensive control of a Genesis SLM MX 532 laser
over serial port using NXP I2C-over-serial protocol.
Protocol Overview:
-----------------
The laser uses NXP I2C-over-serial protocol with packet format:
[0x53] [I2C_ADDR] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50]
For reads:
[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50]
I2C Devices:
-----------
- X9119 digital potentiometer at 0x152 - laser current control
- PCA9555 I/O expander at 0x14a - digital I/O
- AD5254 digital potentiometer at 0x158 - limits control
- ADS7828 ADC at 0x190 - sensor readings
- M24C64 EEPROM at 0x2a4 - configuration storage
Usage:
------
python genesis_laser_control.py
Author: Claude
Date: 2026-01-24
"""
import sys
import time
import struct
from datetime import datetime
from typing import Optional, List, Tuple
from enum import IntEnum
import serial
from serial.tools import list_ports
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTabWidget, QLabel, QSlider, QPushButton, QSpinBox, QCheckBox,
QComboBox, QTextEdit, QLineEdit, QGroupBox, QGridLayout,
QMessageBox, QStatusBar, QProgressBar
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSettings
from PyQt6.QtGui import QFont, QPalette, QColor
# Import core hardware control classes
from hardware.genesis_core import (
I2CAddress, PCA9555Register, ControlBitmask,
SerialComm, I2CProtocol, I2CDevices, LaserControl
)
# ============================================================================
# Main Window GUI
# ============================================================================
class MainWindow(QMainWindow):
"""Main application window with tabbed interface"""
def __init__(self):
super().__init__()
# Initialize backend
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)
# Settings for persistence
self.settings = QSettings("Genesis", "LaserControl")
# Monitoring timer
self.monitor_timer = QTimer()
self.monitor_timer.timeout.connect(self.update_monitoring)
self.monitor_enabled = False
# Initialize UI
self.init_ui()
self.apply_stylesheet()
# Restore settings
self.restore_settings()
# Initial state: all controls disabled
self.update_control_states()
def init_ui(self):
"""Initialize user interface"""
self.setWindowTitle("Genesis SLM MX 532 Laser Control")
self.setMinimumSize(800, 600)
# Central widget with tabs
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QVBoxLayout(central_widget)
# Tab widget
self.tabs = QTabWidget()
layout.addWidget(self.tabs)
# Create tabs
self.tab_basic = self.create_basic_controls_tab()
self.tab_monitoring = self.create_monitoring_tab()
self.tab_advanced = self.create_advanced_tab()
self.tab_config = self.create_configuration_tab()
self.tabs.addTab(self.tab_basic, "Basic Controls")
self.tabs.addTab(self.tab_monitoring, "Monitoring")
self.tabs.addTab(self.tab_advanced, "Advanced")
self.tabs.addTab(self.tab_config, "Configuration")
# Status bar
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage("Disconnected")
def create_basic_controls_tab(self) -> QWidget:
"""Create basic controls tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
# Current control group
current_group = QGroupBox("Current Control")
current_layout = QGridLayout()
current_layout.addWidget(QLabel("Current Command (0-1023):"), 0, 0)
self.current_slider = QSlider(Qt.Orientation.Horizontal)
self.current_slider.setMinimum(0)
self.current_slider.setMaximum(1023)
self.current_slider.setValue(0)
self.current_slider.setToolTip("Set laser current (0-1023)")
self.current_slider.valueChanged.connect(self.on_current_changed)
current_layout.addWidget(self.current_slider, 0, 1)
self.current_spinbox = QSpinBox()
self.current_spinbox.setMinimum(0)
self.current_spinbox.setMaximum(1023)
self.current_spinbox.setValue(0)
self.current_spinbox.valueChanged.connect(self.current_slider.setValue)
current_layout.addWidget(self.current_spinbox, 0, 2)
self.current_percent_label = QLabel("0.0%")
current_layout.addWidget(self.current_percent_label, 0, 3)
current_group.setLayout(current_layout)
layout.addWidget(current_group)
# Power control group
power_group = QGroupBox("Power Control")
power_layout = QGridLayout()
power_layout.addWidget(QLabel("Power Command (0-1023):"), 0, 0)
self.power_slider = QSlider(Qt.Orientation.Horizontal)
self.power_slider.setMinimum(0)
self.power_slider.setMaximum(1023)
self.power_slider.setValue(0)
self.power_slider.setToolTip("Set laser power command (0-1023)")
self.power_slider.valueChanged.connect(self.on_power_changed)
power_layout.addWidget(self.power_slider, 0, 1)
self.power_spinbox = QSpinBox()
self.power_spinbox.setMinimum(0)
self.power_spinbox.setMaximum(1023)
self.power_spinbox.setValue(0)
self.power_spinbox.valueChanged.connect(self.power_slider.setValue)
power_layout.addWidget(self.power_spinbox, 0, 2)
power_group.setLayout(power_layout)
layout.addWidget(power_group)
# Toggle controls group
toggle_group = QGroupBox("Digital Controls")
toggle_layout = QGridLayout()
self.shutter_btn = QPushButton("Shutter: CLOSED")
self.shutter_btn.setCheckable(True)
self.shutter_btn.setToolTip("Open/close laser shutter")
self.shutter_btn.clicked.connect(self.on_shutter_toggled)
toggle_layout.addWidget(self.shutter_btn, 0, 0)
self.keyswitch_btn = QPushButton("Keyswitch: OFF")
self.keyswitch_btn.setCheckable(True)
self.keyswitch_btn.setToolTip("Enable/disable keyswitch")
self.keyswitch_btn.clicked.connect(self.on_keyswitch_toggled)
toggle_layout.addWidget(self.keyswitch_btn, 0, 1)
self.remote_enable_btn = QPushButton("Remote: DISABLED")
self.remote_enable_btn.setCheckable(True)
self.remote_enable_btn.setToolTip("Enable/disable remote control")
self.remote_enable_btn.clicked.connect(self.on_remote_enable_toggled)
toggle_layout.addWidget(self.remote_enable_btn, 1, 0)
self.analog_enable_btn = QPushButton("Analog Input: DISABLED")
self.analog_enable_btn.setCheckable(True)
self.analog_enable_btn.setToolTip("Enable/disable analog input")
self.analog_enable_btn.clicked.connect(self.on_analog_enable_toggled)
toggle_layout.addWidget(self.analog_enable_btn, 1, 1)
self.current_mode_btn = QPushButton("Current Mode: OFF")
self.current_mode_btn.setCheckable(True)
self.current_mode_btn.setToolTip("Enable/disable current mode")
self.current_mode_btn.clicked.connect(self.on_current_mode_toggled)
toggle_layout.addWidget(self.current_mode_btn, 2, 0)
toggle_group.setLayout(toggle_layout)
layout.addWidget(toggle_group)
# Emergency stop
self.emergency_stop_btn = QPushButton("EMERGENCY STOP")
self.emergency_stop_btn.setStyleSheet("background-color: #ff0000; color: white; font-weight: bold; font-size: 14pt;")
self.emergency_stop_btn.clicked.connect(self.on_emergency_stop)
self.emergency_stop_btn.setEnabled(True) # Always enabled
layout.addWidget(self.emergency_stop_btn)
layout.addStretch()
return tab
def create_monitoring_tab(self) -> QWidget:
"""Create monitoring tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
# Auto-refresh control
refresh_layout = QHBoxLayout()
self.auto_refresh_check = QCheckBox("Auto-refresh")
self.auto_refresh_check.stateChanged.connect(self.on_auto_refresh_toggled)
refresh_layout.addWidget(self.auto_refresh_check)
refresh_layout.addWidget(QLabel("Interval (ms):"))
self.refresh_interval_spin = QSpinBox()
self.refresh_interval_spin.setMinimum(100)
self.refresh_interval_spin.setMaximum(5000)
self.refresh_interval_spin.setValue(500)
self.refresh_interval_spin.setSingleStep(100)
refresh_layout.addWidget(self.refresh_interval_spin)
self.refresh_now_btn = QPushButton("Refresh Now")
self.refresh_now_btn.clicked.connect(self.update_monitoring)
refresh_layout.addWidget(self.refresh_now_btn)
refresh_layout.addStretch()
layout.addLayout(refresh_layout)
# Readings group
readings_group = QGroupBox("Sensor Readings")
readings_layout = QGridLayout()
readings_layout.addWidget(QLabel("Actual Current:"), 0, 0)
self.current_actual_label = QLabel("---")
readings_layout.addWidget(self.current_actual_label, 0, 1)
readings_layout.addWidget(QLabel("Interlock Status:"), 1, 0)
self.interlock_label = QLabel("---")
readings_layout.addWidget(self.interlock_label, 1, 1)
readings_layout.addWidget(QLabel("LDD Enable:"), 2, 0)
self.ldd_enable_label = QLabel("---")
readings_layout.addWidget(self.ldd_enable_label, 2, 1)
readings_layout.addWidget(QLabel("PS Glue In:"), 3, 0)
self.psglue_in_label = QLabel("---")
readings_layout.addWidget(self.psglue_in_label, 3, 1)
readings_layout.addWidget(QLabel("PS Glue Out:"), 4, 0)
self.psglue_out_label = QLabel("---")
readings_layout.addWidget(self.psglue_out_label, 4, 1)
readings_layout.addWidget(QLabel("Head DIO:"), 5, 0)
self.head_dio_label = QLabel("---")
readings_layout.addWidget(self.head_dio_label, 5, 1)
readings_group.setLayout(readings_layout)
layout.addWidget(readings_group)
# Laser info group
info_group = QGroupBox("Laser Information")
info_layout = QGridLayout()
info_layout.addWidget(QLabel("Model:"), 0, 0)
self.laser_model_label = QLabel("Genesis SLM MX 532")
info_layout.addWidget(self.laser_model_label, 0, 1)
info_layout.addWidget(QLabel("Wavelength:"), 1, 0)
self.wavelength_label = QLabel("532 nm")
info_layout.addWidget(self.wavelength_label, 1, 1)
info_group.setLayout(info_layout)
layout.addWidget(info_group)
layout.addStretch()
return tab
def create_advanced_tab(self) -> QWidget:
"""Create advanced tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
# Raw I2C packet sender
packet_group = QGroupBox("Raw I2C Packet Sender")
packet_layout = QGridLayout()
packet_layout.addWidget(QLabel("I2C Address (hex):"), 0, 0)
self.i2c_addr_edit = QLineEdit("52")
self.i2c_addr_edit.setMaximumWidth(100)
packet_layout.addWidget(self.i2c_addr_edit, 0, 1)
packet_layout.addWidget(QLabel("Command (hex):"), 1, 0)
self.i2c_cmd_edit = QLineEdit("a0")
self.i2c_cmd_edit.setMaximumWidth(200)
packet_layout.addWidget(self.i2c_cmd_edit, 1, 1)
packet_layout.addWidget(QLabel("Data (hex):"), 2, 0)
self.i2c_data_edit = QLineEdit("00 00")
packet_layout.addWidget(self.i2c_data_edit, 2, 1)
self.send_packet_btn = QPushButton("Send Packet")
self.send_packet_btn.clicked.connect(self.on_send_raw_packet)
packet_layout.addWidget(self.send_packet_btn, 3, 0, 1, 2)
self.packet_response_label = QLabel("Response: ---")
packet_layout.addWidget(self.packet_response_label, 4, 0, 1, 2)
packet_group.setLayout(packet_layout)
layout.addWidget(packet_group)
# Packet log
log_group = QGroupBox("Packet Capture Log")
log_layout = QVBoxLayout()
log_controls = QHBoxLayout()
self.clear_log_btn = QPushButton("Clear Log")
self.clear_log_btn.clicked.connect(self.on_clear_log)
log_controls.addWidget(self.clear_log_btn)
log_controls.addStretch()
log_layout.addLayout(log_controls)
self.packet_log_text = QTextEdit()
self.packet_log_text.setReadOnly(True)
self.packet_log_text.setFont(QFont("Courier", 9))
log_layout.addWidget(self.packet_log_text)
log_group.setLayout(log_layout)
layout.addWidget(log_group)
# Update log periodically
self.log_timer = QTimer()
self.log_timer.timeout.connect(self.update_packet_log)
self.log_timer.start(1000) # Update every second
return tab
def create_configuration_tab(self) -> QWidget:
"""Create configuration tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
# Serial port configuration
serial_group = QGroupBox("Serial Port Configuration")
serial_layout = QGridLayout()
serial_layout.addWidget(QLabel("Port:"), 0, 0)
self.port_combo = QComboBox()
self.refresh_ports()
serial_layout.addWidget(self.port_combo, 0, 1)
self.refresh_ports_btn = QPushButton("Refresh Ports")
self.refresh_ports_btn.clicked.connect(self.refresh_ports)
serial_layout.addWidget(self.refresh_ports_btn, 0, 2)
serial_layout.addWidget(QLabel("Baud Rate:"), 1, 0)
self.baudrate_combo = QComboBox()
self.baudrate_combo.addItems(["9600", "19200", "38400", "57600", "115200"])
self.baudrate_combo.setCurrentText("9600")
serial_layout.addWidget(self.baudrate_combo, 1, 1)
self.connect_btn = QPushButton("Connect")
self.connect_btn.clicked.connect(self.on_connect_clicked)
serial_layout.addWidget(self.connect_btn, 2, 0, 1, 3)
serial_group.setLayout(serial_layout)
layout.addWidget(serial_group)
# DTR/RTS control
control_group = QGroupBox("Serial Control Lines")
control_layout = QVBoxLayout()
self.dtr_check = QCheckBox("DTR (Data Terminal Ready)")
self.dtr_check.stateChanged.connect(self.on_dtr_changed)
control_layout.addWidget(self.dtr_check)
self.rts_check = QCheckBox("RTS (Request To Send)")
self.rts_check.stateChanged.connect(self.on_rts_changed)
control_layout.addWidget(self.rts_check)
control_group.setLayout(control_layout)
layout.addWidget(control_group)
# About section
about_group = QGroupBox("About")
about_layout = QVBoxLayout()
about_text = QLabel(
"Genesis SLM MX 532 Laser Control\n\n"
"Protocol: NXP I2C-over-serial\n"
"Baud Rate: 9600 (default)\n\n"
"This application provides comprehensive control of the Genesis laser\n"
"using I2C devices over serial communication."
)
about_layout.addWidget(about_text)
about_group.setLayout(about_layout)
layout.addWidget(about_group)
layout.addStretch()
return tab
# ------------------------------------------------------------------------
# Event Handlers - Basic Controls
# ------------------------------------------------------------------------
def on_current_changed(self, value: int):
"""Handle current slider change"""
self.current_spinbox.setValue(value)
percent = (value / 1023.0) * 100.0
self.current_percent_label.setText(f"{percent:.1f}%")
if self.serial_comm.is_connected():
if self.laser_control.set_current(value):
self.status_bar.showMessage(f"Current set to {value} ({percent:.1f}%)", 2000)
def on_power_changed(self, value: int):
"""Handle power slider change"""
self.power_spinbox.setValue(value)
if self.serial_comm.is_connected():
if self.laser_control.set_power_cmd(value):
self.status_bar.showMessage(f"Power command set to {value}", 2000)
def on_shutter_toggled(self, checked: bool):
"""Handle shutter toggle"""
if checked and self.current_slider.value() > 0:
reply = QMessageBox.question(
self,
"Confirm Shutter Open",
"Current is set above 0. Are you sure you want to open the shutter?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
self.shutter_btn.setChecked(False)
return
if self.laser_control.set_shutter(checked):
self.shutter_btn.setText("Shutter: OPEN" if checked else "Shutter: CLOSED")
self.status_bar.showMessage(f"Shutter {'opened' if checked else 'closed'}", 2000)
else:
self.shutter_btn.setChecked(not checked)
def on_keyswitch_toggled(self, checked: bool):
"""Handle keyswitch toggle"""
if self.laser_control.set_keyswitch(checked):
self.keyswitch_btn.setText("Keyswitch: ON" if checked else "Keyswitch: OFF")
self.status_bar.showMessage(f"Keyswitch {'enabled' if checked else 'disabled'}", 2000)
else:
self.keyswitch_btn.setChecked(not checked)
def on_remote_enable_toggled(self, checked: bool):
"""Handle remote enable toggle"""
if self.laser_control.set_remote_enable(checked):
self.remote_enable_btn.setText("Remote: ENABLED" if checked else "Remote: DISABLED")
self.status_bar.showMessage(f"Remote control {'enabled' if checked else 'disabled'}", 2000)
else:
self.remote_enable_btn.setChecked(not checked)
def on_analog_enable_toggled(self, checked: bool):
"""Handle analog enable toggle"""
if self.laser_control.set_analog_enable(checked):
self.analog_enable_btn.setText("Analog Input: ENABLED" if checked else "Analog Input: DISABLED")
self.status_bar.showMessage(f"Analog input {'enabled' if checked else 'disabled'}", 2000)
else:
self.analog_enable_btn.setChecked(not checked)
def on_current_mode_toggled(self, checked: bool):
"""Handle current mode toggle"""
if self.laser_control.set_current_mode(checked):
self.current_mode_btn.setText("Current Mode: ON" if checked else "Current Mode: OFF")
self.status_bar.showMessage(f"Current mode {'enabled' if checked else 'disabled'}", 2000)
else:
self.current_mode_btn.setChecked(not checked)
def on_emergency_stop(self):
"""Handle emergency stop button"""
if self.laser_control.emergency_stop():
# Reset UI to safe state
self.current_slider.setValue(0)
self.power_slider.setValue(0)
self.shutter_btn.setChecked(False)
self.shutter_btn.setText("Shutter: CLOSED")
self.keyswitch_btn.setChecked(False)
self.keyswitch_btn.setText("Keyswitch: OFF")
self.status_bar.showMessage("EMERGENCY STOP ACTIVATED", 5000)
QMessageBox.warning(self, "Emergency Stop", "Emergency stop activated!\nLaser is now in safe state.")
# ------------------------------------------------------------------------
# Event Handlers - Monitoring
# ------------------------------------------------------------------------
def on_auto_refresh_toggled(self, state):
"""Handle auto-refresh toggle"""
if state:
interval = self.refresh_interval_spin.value()
self.monitor_timer.start(interval)
self.monitor_enabled = True
else:
self.monitor_timer.stop()
self.monitor_enabled = False
def update_monitoring(self):
"""Update monitoring readings"""
if not self.serial_comm.is_connected():
return
# Read actual current
current = self.laser_control.get_current_actual()
if current is not None:
self.current_actual_label.setText(f"{current} counts")
else:
self.current_actual_label.setText("Error reading")
# Read interlock status
interlock = self.laser_control.get_interlock_status()
if interlock is not None:
if interlock:
self.interlock_label.setText("OK")
self.interlock_label.setStyleSheet("color: green; font-weight: bold;")
else:
self.interlock_label.setText("FAULT")
self.interlock_label.setStyleSheet("color: red; font-weight: bold;")
else:
self.interlock_label.setText("Error reading")
self.interlock_label.setStyleSheet("")
# Read LDD enable
ldd = self.laser_control.get_ldd_enable_status()
if ldd is not None:
self.ldd_enable_label.setText("Enabled" if ldd else "Disabled")
else:
self.ldd_enable_label.setText("Error reading")
# Read PS glue status
psglue_in = self.laser_control.get_psglue_in_status()
if psglue_in is not None:
self.psglue_in_label.setText(f"0x{psglue_in:02x}")
else:
self.psglue_in_label.setText("Error reading")
psglue_out = self.laser_control.get_psglue_out_status()
if psglue_out is not None:
self.psglue_out_label.setText(f"0x{psglue_out:02x}")
else:
self.psglue_out_label.setText("Error reading")
# Read head DIO
head_dio = self.laser_control.get_head_dio_status()
if head_dio is not None:
self.head_dio_label.setText(f"0x{head_dio:02x}")
else:
self.head_dio_label.setText("Error reading")
# ------------------------------------------------------------------------
# Event Handlers - Advanced
# ------------------------------------------------------------------------
def on_send_raw_packet(self):
"""Handle raw packet send"""
if not self.serial_comm.is_connected():
QMessageBox.warning(self, "Not Connected", "Please connect to serial port first.")
return
try:
# Parse inputs
addr = int(self.i2c_addr_edit.text(), 16)
cmd_hex = self.i2c_cmd_edit.text().replace(" ", "")
cmd = bytes.fromhex(cmd_hex)
data_hex = self.i2c_data_edit.text().replace(" ", "")
data = bytes.fromhex(data_hex) if data_hex else b''
# Send packet
if self.i2c_protocol.write(addr, cmd, data):
self.packet_response_label.setText("Response: Packet sent successfully")
self.status_bar.showMessage("Raw packet sent", 2000)
else:
self.packet_response_label.setText("Response: Send failed")
except ValueError as e:
QMessageBox.warning(self, "Invalid Input", f"Invalid hex value: {e}")
def on_clear_log(self):
"""Handle clear log button"""
self.serial_comm.clear_packet_log()
self.packet_log_text.clear()
def update_packet_log(self):
"""Update packet log display"""
if not self.serial_comm.is_connected():
return
log_entries = self.serial_comm.get_packet_log(100)
if log_entries:
self.packet_log_text.setPlainText("\n".join(log_entries))
# Scroll to bottom
scrollbar = self.packet_log_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
# ------------------------------------------------------------------------
# Event Handlers - Configuration
# ------------------------------------------------------------------------
def on_connect_clicked(self):
"""Handle connect/disconnect button"""
if self.serial_comm.is_connected():
# Disconnect
self.laser_control.enter_safe_state()
self.serial_comm.disconnect()
self.connect_btn.setText("Connect")
self.status_bar.showMessage("Disconnected")
self.update_control_states()
else:
# Connect
port = self.port_combo.currentText()
baudrate = int(self.baudrate_combo.currentText())
if self.serial_comm.connect(port, baudrate):
self.connect_btn.setText("Disconnect")
self.status_bar.showMessage(f"Connected to {port} at {baudrate} baud")
self.update_control_states()
else:
QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}")
def on_dtr_changed(self, state):
"""Handle DTR checkbox change"""
if self.serial_comm.is_connected():
self.serial_comm.port.dtr = bool(state)
def on_rts_changed(self, state):
"""Handle RTS checkbox change"""
if self.serial_comm.is_connected():
self.serial_comm.port.rts = bool(state)
def refresh_ports(self):
"""Refresh available serial ports"""
self.port_combo.clear()
ports = list_ports.comports()
for port in ports:
self.port_combo.addItem(port.device)
# Add default if no ports found
if self.port_combo.count() == 0:
self.port_combo.addItem("/dev/ttyUSB0")
# ------------------------------------------------------------------------
# UI Update Functions
# ------------------------------------------------------------------------
def update_control_states(self):
"""Enable/disable controls based on connection state"""
connected = self.serial_comm.is_connected()
# Basic controls
self.current_slider.setEnabled(connected)
self.current_spinbox.setEnabled(connected)
self.power_slider.setEnabled(connected)
self.power_spinbox.setEnabled(connected)
self.shutter_btn.setEnabled(connected)
self.keyswitch_btn.setEnabled(connected)
self.remote_enable_btn.setEnabled(connected)
self.analog_enable_btn.setEnabled(connected)
self.current_mode_btn.setEnabled(connected)
# Monitoring controls
self.auto_refresh_check.setEnabled(connected)
self.refresh_now_btn.setEnabled(connected)
# Advanced controls
self.send_packet_btn.setEnabled(connected)
# Configuration controls
self.port_combo.setEnabled(not connected)
self.baudrate_combo.setEnabled(not connected)
self.dtr_check.setEnabled(connected)
self.rts_check.setEnabled(connected)
def apply_stylesheet(self):
"""Apply custom stylesheet for professional look"""
self.setStyleSheet("""
QGroupBox {
font-weight: bold;
border: 2px solid #cccccc;
border-radius: 5px;
margin-top: 10px;
padding-top: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px 0 5px;
}
QPushButton {
padding: 5px 10px;
border-radius: 3px;
background-color: #e0e0e0;
}
QPushButton:hover {
background-color: #d0d0d0;
}
QPushButton:pressed {
background-color: #c0c0c0;
}
QPushButton:disabled {
background-color: #f0f0f0;
color: #a0a0a0;
}
QPushButton:checked {
background-color: #4CAF50;
color: white;
}
QSlider::groove:horizontal {
border: 1px solid #999999;
height: 8px;
background: #e0e0e0;
margin: 2px 0;
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #4CAF50;
border: 1px solid #5c5c5c;
width: 18px;
margin: -5px 0;
border-radius: 9px;
}
""")
# ------------------------------------------------------------------------
# Settings Persistence
# ------------------------------------------------------------------------
def save_settings(self):
"""Save window geometry and settings"""
self.settings.setValue("geometry", self.saveGeometry())
self.settings.setValue("port", self.port_combo.currentText())
self.settings.setValue("baudrate", self.baudrate_combo.currentText())
def restore_settings(self):
"""Restore window geometry and settings"""
geometry = self.settings.value("geometry")
if geometry:
self.restoreGeometry(geometry)
port = self.settings.value("port")
if port:
index = self.port_combo.findText(port)
if index >= 0:
self.port_combo.setCurrentIndex(index)
baudrate = self.settings.value("baudrate")
if baudrate:
index = self.baudrate_combo.findText(baudrate)
if index >= 0:
self.baudrate_combo.setCurrentIndex(index)
def closeEvent(self, event):
"""Handle window close event"""
if self.serial_comm.is_connected():
reply = QMessageBox.question(
self,
"Confirm Exit",
"Laser is connected. Enter safe state and disconnect?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.laser_control.enter_safe_state()
self.serial_comm.disconnect()
else:
event.ignore()
return
self.save_settings()
event.accept()
# ============================================================================
# Main Entry Point
# ============================================================================
def main():
"""Main application entry point"""
app = QApplication(sys.argv)
app.setApplicationName("Genesis Laser Control")
app.setOrganizationName("Genesis")
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+1445
View File
File diff suppressed because it is too large Load Diff