Initial commit: merge nuescan, pymso, pybbd202, and pypewpewhops into scanengine-3

- Merged four separate hardware control projects into unified platform
- Created unified requirements.txt with all dependencies
- Added comprehensive .gitignore
- Added project overview README

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales [M S E]
2026-01-16 20:11:31 -06:00
commit fc43fbe4b0
65 changed files with 19127 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
# 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
*.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
# .python-version
# pipenv
#Pipfile.lock
# UV
#uv.lock
# poetry
#poetry.lock
#poetry.toml
# pdm
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
#pixi.lock
.pixi
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Project-specific virtual environments
nuenv/
msoenv/
msodev/
# 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
#.idea/
# Abstra
.abstra/
# Visual Studio Code
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
+57
View File
@@ -0,0 +1,57 @@
# scanengine-3
A unified scanning and instrumentation control platform combining multiple hardware control modules.
## Overview
scanengine-3 merges four previously separate projects into a single, cohesive platform:
- **nuescan**: SRAS scan planning and control software with PyQt6 GUI
- **pymso**: Tektronix oscilloscope control and data acquisition
- **pybbd202**: Thorlabs BBD203/MLS203 motor controller driver
- **pypewpewhops**: Coherent HOPS laser control via I2C
## Project Structure
```
scanengine-3/
├── nuescan/ # Main scan control application with GUI
├── pymso/ # Oscilloscope control module
├── pybbd202/ # Stage controller driver
├── pypewpewhops/ # Laser control module
├── requirements.txt # Unified dependencies
└── README.md # This file
```
## Installation
1. Create a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
## Dependencies
- **PyQt6**: GUI framework for nuescan
- **pyserial**: Serial communication for hardware interfaces
- **pyvisa/pyvisa-py**: VISA instrument control for oscilloscopes
- **pyftdi**: FTDI device support for laser and stage controllers
## Usage
Refer to the README files in each subdirectory for specific usage instructions:
- `nuescan/README.md` - Main application usage
- `pymso/` - Oscilloscope control examples
- `pybbd202/README.md` - Stage controller documentation
- `pypewpewhops/README.md` - Laser control documentation
## License
Each module retains its original license. See individual subdirectories for license information.
+207
View File
@@ -0,0 +1,207 @@
# 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__/
+300
View File
@@ -0,0 +1,300 @@
# BBD203 Connection Guide
## Quick Start - Connecting Your BBD203 Controller
This guide explains how to connect your ThorLabs BBD203 motor controller to nueScan using the simplified serial number method.
---
## Step 1: Physical Connection
1. **Connect USB Cable**
- Connect the USB cable from your BBD203 controller to your computer
- Wait for Windows/Linux to recognize the device
- No special drivers needed (uses standard FTDI USB-Serial)
2. **Power On Controller**
- Ensure BBD203 is powered on
- Front panel should be lit
---
## Step 2: Find Serial Number
The serial number is printed on a label on your BBD203 controller.
**Common Locations:**
- Back panel of the controller
- Side panel
- Original packaging
**Format:**
- Usually 8 digits (e.g., `83123456`)
- May include letters (e.g., `83A12345`)
**Example Label:**
```
ThorLabs BBD203
S/N: 83123456
```
---
## Step 3: Connect in nueScan
### Using the GUI
1. **Launch nueScan**
```bash
python -m nuescan
```
2. **Enter Serial Number**
- Locate the "ThorLABS MLS Stage Serial:" field at the top of the window
- Type your serial number (e.g., `83123456`)
3. **Click Connect**
- Click the "Connect" button next to the serial field
- Wait 1-2 seconds for connection
4. **Success!**
- If successful, you'll see a confirmation dialog
- Button changes to "Disconnect"
- All 3 motor channels are now enabled
### Programmatic Connection
```python
from hardware.thorlabs_stage import ThorLabsStage
# Create stage instance
stage = ThorLabsStage(encoder_counts_per_mm=20000)
# Connect by serial number
success = stage.connect('83123456')
if success:
print("Connected! Ready to home axes.")
else:
print("Connection failed.")
```
---
## Troubleshooting
### "Device not found" Error
**Problem:** Connection fails with "Could not find BBD203 with serial number..."
**Solutions:**
1. **Check USB Connection**
- Ensure USB cable is fully inserted
- Try a different USB port
- Try a different USB cable
2. **Verify Serial Number**
- Double-check the serial number on the controller label
- Ensure no typos (0 vs O, 1 vs I, etc.)
3. **List Available Devices**
- The error dialog will show all detected ThorLabs devices
- Check if your device appears with a different serial number
- If no devices shown, check USB connection and drivers
4. **Windows: Check Device Manager**
- Open Device Manager
- Look under "Ports (COM & LPT)"
- Should see "USB Serial Port (COMx)" with FTDI in description
- If device shows with "!" icon, driver issue
5. **Linux: Check Permissions**
```bash
# Check if device is detected
lsusb | grep -i ftdi
# Check serial ports
ls -l /dev/ttyUSB*
# Add user to dialout group (may require logout)
sudo usermod -a -G dialout $USER
```
### Connection Succeeds but No Response
**Problem:** Connection successful but motors don't respond
**Solutions:**
1. **Check Power**
- Verify motors are connected and powered
- Check motor power LEDs on BBD203 front panel
2. **Enable Channels**
- Channels should auto-enable on connection
- Check status indicators in UI
3. **Home Axes**
- Axes may need homing before movement
- Try homing each axis
### Multiple Controllers
**Problem:** You have multiple BBD203 controllers connected
**Solution:**
- Each controller has a unique serial number
- Connect to specific controller by entering its serial number
- Error dialog will show all connected devices
---
## What Happens During Connection
### Automatic Process
When you click "Connect", the following happens automatically:
1. **USB Enumeration**
- Scans all USB ports
- Finds ThorLabs devices (FTDI vendor ID: 0x0403)
- Matches your serial number
2. **Port Assignment**
- Determines the COM port (e.g., COM3, /dev/ttyUSB0)
- Opens serial connection at 115200 baud
3. **Controller Initialization**
- Requests hardware information
- Enables automatic status updates
- Enables all 3 motor channels
4. **Default Configuration**
- Sets velocity: 1.0 mm/s
- Sets acceleration: 5.0 mm/s²
- Starts position monitoring
### Status Updates
After connection:
- Position updates received every ~100ms
- Status bits monitored (homed, moving, errors)
- Move completion notifications enabled
---
## Advanced Configuration
### Custom Encoder Resolution
If you're using a stage with different encoder resolution:
```python
# Example: Stage with 2,000 counts/mm instead of default 20,000
stage = ThorLabsStage(encoder_counts_per_mm=2000)
stage.connect('83123456')
```
Common resolutions:
- **MLS203**: 20,000 counts/mm (default)
- **DDS220**: 2,000 counts/mm
- **Custom**: Check your stage specifications
### Direct Port Connection (Not Recommended)
If you need to connect to a specific port instead of using serial number:
```python
from hardware.bbd203_driver import BBD203Driver
driver = BBD203Driver()
driver.connect('COM3') # or '/dev/ttyUSB0' on Linux
```
---
## System Requirements
### Operating Systems
- ✅ Windows 7/8/10/11
- ✅ Linux (Ubuntu, Fedora, etc.)
- ✅ macOS (with FTDI driver)
### Dependencies
- Python 3.8+
- PySerial 3.5+
- PyQt6 6.4+
### USB Requirements
- USB 2.0 or higher
- FTDI USB-Serial drivers (usually automatic)
---
## Next Steps
After successful connection:
1. **Home the Axes**
- Required before first movement
- Establishes zero position reference
2. **Test Movement**
- Try small movements to verify operation
- Check position feedback in UI
3. **Configure Scan Parameters**
- Set scan area (X/Y start, delta)
- Set row spacing
- Configure velocity if needed
4. **Begin Scanning**
- All systems should show "Ready"
- Click "Begin Scan" to start
---
## Support
For issues with the BBD203 driver or connection:
1. **Check Debug Output**
- Console window shows connection details
- Look for "INFO:", "DEBUG:", and "ERROR:" messages
2. **Review Driver Documentation**
- `BBD203_DRIVER_README.md` - Complete driver reference
- `BBD203_Communications_Protocol.md` - Protocol details
3. **ThorLabs Support**
- For hardware issues: techsupport@thorlabs.com
- For driver/protocol questions: Review APT documentation
---
## Example Session
```
$ python -m nuescan
# UI appears
# Enter serial: 83123456
# Click Connect
# Console output:
INFO: Connecting to BBD203 with serial number 83123456
DEBUG: Found ThorLabs device - Serial: 83123456, Port: COM3
INFO: Found device 83123456 on port COM3
INFO: Connecting to BBD203 on COM3
INFO: Successfully connected to BBD203 on COM3
INFO: Stage connected and channels enabled
# Success dialog appears
# Button changes to "Disconnect"
# Ready to home and move!
```
---
*For detailed technical information, see `BBD203_DRIVER_README.md`*
+702
View File
@@ -0,0 +1,702 @@
# BBD203 Motor Controller - APT Communications Protocol
## Version 42.1 - Extracted Documentation
This document contains only the information relevant to the **BBD203 3-Channel Benchtop Brushless DC Motor Controller**, extracted from the complete Thorlabs APT Communications Protocol documentation.
---
## Table of Contents
1. [Introduction](#1-introduction)
2. [General Protocol Information](#2-general-protocol-information)
3. [BBD203 Specifications](#3-bbd203-specifications)
4. [Message Format](#4-message-format)
5. [BBD203 Applicable Messages](#5-bbd203-applicable-messages)
6. [Command Examples](#6-command-examples)
7. [Important Notes for BBD203](#7-important-notes-for-bbd203)
---
## 1. Introduction
The BBD203 is a 3-channel benchtop brushless DC motor controller that is part of Thorlabs' APT motion control system. This document describes the communication protocol used to control the BBD203 via USB or RS232 interfaces.
### 1.1 Device Overview
The BBD203 provides independent control of up to three brushless DC motors with the following key features:
- 3 independent motor channels
- USB and RS232 communication interfaces
- Closed-loop position and velocity control
- Encoder feedback support
- Digital I/O for triggering and synchronization
- Compatible with Thorlabs' APT software suite
### 1.2 Related Controllers
The BBD203 shares its protocol with other controllers in the BBD series:
- **BBD201** - 1 Channel Benchtop Brushless DC Motor Driver
- **BBD202** - 2 Channel Benchtop Brushless DC Motor Driver
- **BBD203** - 3 Channel Benchtop Brushless DC Motor Driver
---
## 2. General Protocol Information
### 2.1 Communication Format
All communications with the BBD203 use a binary message protocol. Messages consist of a 6-byte header followed by an optional data packet.
#### Header Structure (6 bytes):
| Byte | Description |
|------|------------|
| 0-1 | Message ID (16-bit, little-endian) |
| 2 | Data length (bytes) or parameter 1 |
| 3 | Data length MSB or parameter 2 |
| 4 | Destination |
| 5 | Source |
#### Destination Byte Values:
| Value | Description |
|-------|------------|
| 0x50 | Generic USB device |
| 0x11 | Rack controller (card slot unit) |
| 0x21 | Bay 1 / Channel 1 |
| 0x22 | Bay 2 / Channel 2 |
| 0x23 | Bay 3 / Channel 3 |
#### Source Byte Values:
| Value | Description |
|-------|------------|
| 0x01 | Host PC |
### 2.2 Channel Addressing
The BBD203 has three motor channels. When addressing specific channels:
- **Channel 1**: Use destination byte `0x21`
- **Channel 2**: Use destination byte `0x22`
- **Channel 3**: Use destination byte `0x23`
- **All channels**: Use destination byte `0x11`
**Important**: Although the BBD203 has 3 channels, each channel operates as an independent single-channel controller. In the data packet's channel identifier field, always use Channel 1 (`0x01 0x00`), and use the destination byte in the header to specify the physical channel.
### 2.3 Data Types
| Type | Size | Description |
|------|------|------------|
| char | 1 byte | 8-bit signed integer |
| short | 2 bytes | 16-bit signed integer (little-endian) |
| long | 4 bytes | 32-bit signed integer (little-endian) |
| word | 2 bytes | 16-bit unsigned integer (little-endian) |
| dword | 4 bytes | 32-bit unsigned integer (little-endian) |
---
## 3. BBD203 Specifications
### 3.1 Encoder and Position Scaling
For BBD203 controllers, position and velocity values are scaled based on encoder counts. The scaling formulas are:
#### Position Scaling:
```
POS_APT = EncCnt × Pos
```
Where:
- `POS_APT` = Position value to send/receive via APT protocol
- `EncCnt` = Encoder counts per unit (stage-specific)
- `Pos` = Position in real units (mm, degrees, etc.)
#### Velocity Scaling:
```
VEL_APT = EncCnt × T × 65536 × Vel
```
Where:
- `VEL_APT` = Velocity value to send/receive via APT protocol
- `T` = 102.4 × 10⁻⁶ seconds (controller sample time)
- `Vel` = Velocity in real units per second
#### Acceleration Scaling:
```
ACC_APT = EncCnt × T² × 65536 × Acc
```
Where:
- `ACC_APT` = Acceleration value to send/receive via APT protocol
- `T²` = (102.4 × 10⁻⁶)²
- `Acc` = Acceleration in real units per second²
### 3.2 Example Scaling Values
For a stage with 20,000 encoder counts per mm:
- Position of 10 mm = 200,000 counts
- Velocity of 1 mm/s = 134,218 APT units
- Acceleration of 1 mm/s² = 13.7 APT units
---
## 4. Message Format
### 4.1 Message Types
Messages are categorized into several types:
- **MOD** - Module control messages (identify, enable/disable)
- **HW** - Hardware information and control
- **MOT** - Motor control messages (move, velocity, position)
- **RACK** - Rack and bay status messages
### 4.2 Message Flow
1. **Command Messages**: Sent from host to controller
2. **Request Messages**: Request data from controller
3. **Get Messages**: Controller response with requested data
4. **Status Messages**: Unsolicited updates from controller
---
## 5. BBD203 Applicable Messages
### 5.1 Module Control Messages
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_MOD_IDENTIFY | 0x0223 | Host→Device | Flash front panel LEDs to identify unit |
| MGMSG_MOD_SET_CHANENABLESTATE | 0x0210 | Host→Device | Enable/disable a motor channel |
| MGMSG_MOD_REQ_CHANENABLESTATE | 0x0211 | Host→Device | Request channel enable state |
| MGMSG_MOD_GET_CHANENABLESTATE | 0x0212 | Device→Host | Get channel enable state response |
### 5.2 Hardware Control Messages
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_HW_DISCONNECT | 0x0002 | Host→Device | Disconnect from USB bus |
| MGMSG_HW_RESPONSE | 0x0080 | Device→Host | Response/error message |
| MGMSG_HW_RICHRESPONSE | 0x0081 | Device→Host | Detailed response with error info |
| MGMSG_HW_START_UPDATEMSGS | 0x0011 | Host→Device | Start automatic status updates |
| MGMSG_HW_STOP_UPDATEMSGS | 0x0012 | Host→Device | Stop automatic status updates |
| MGMSG_HW_REQ_INFO | 0x0005 | Host→Device | Request hardware information |
| MGMSG_HW_GET_INFO | 0x0006 | Device→Host | Hardware information response |
### 5.3 Rack Status Messages
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_RACK_REQ_BAYUSED | 0x0060 | Host→Device | Request which bays are occupied |
| MGMSG_RACK_GET_BAYUSED | 0x0061 | Device→Host | Bay occupation status |
| MGMSG_RACK_REQ_STATUSBITS | 0x0226 | Host→Device | Request rack status bits |
| MGMSG_RACK_GET_STATUSBITS | 0x0227 | Device→Host | Rack status bits response |
| MGMSG_RACK_SET_DIGOUTPUTS | 0x0228 | Host→Device | Set digital outputs |
| MGMSG_RACK_REQ_DIGOUTPUTS | 0x0229 | Host→Device | Request digital output states |
| MGMSG_RACK_GET_DIGOUTPUTS | 0x0230 | Device→Host | Digital output states |
### 5.4 Motor Control Messages - Basic
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_MOT_SET_POSCOUNTER | 0x0410 | Host→Device | Set position counter value |
| MGMSG_MOT_REQ_POSCOUNTER | 0x0411 | Host→Device | Request position counter |
| MGMSG_MOT_GET_POSCOUNTER | 0x0412 | Device→Host | Position counter value |
| MGMSG_MOT_SET_ENCCOUNTER | 0x0409 | Host→Device | Set encoder counter value |
| MGMSG_MOT_REQ_ENCCOUNTER | 0x040A | Host→Device | Request encoder counter |
| MGMSG_MOT_GET_ENCCOUNTER | 0x040B | Device→Host | Encoder counter value |
### 5.5 Motor Control Messages - Homing
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_MOT_SET_HOMEPARAMS | 0x0440 | Host→Device | Set homing parameters |
| MGMSG_MOT_REQ_HOMEPARAMS | 0x0441 | Host→Device | Request homing parameters |
| MGMSG_MOT_GET_HOMEPARAMS | 0x0442 | Device→Host | Homing parameters |
| MGMSG_MOT_MOVE_HOME | 0x0443 | Host→Device | Start homing sequence |
| MGMSG_MOT_MOVE_HOMED | 0x0444 | Device→Host | Homing completed |
### 5.6 Motor Control Messages - Movement
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_MOT_SET_MOVERELPARAMS | 0x0445 | Host→Device | Set relative move parameters |
| MGMSG_MOT_REQ_MOVERELPARAMS | 0x0446 | Host→Device | Request relative move parameters |
| MGMSG_MOT_GET_MOVERELPARAMS | 0x0447 | Device→Host | Relative move parameters |
| MGMSG_MOT_MOVE_RELATIVE | 0x0448 | Host→Device | Start relative move |
| MGMSG_MOT_SET_MOVEABSPARAMS | 0x0450 | Host→Device | Set absolute move parameters |
| MGMSG_MOT_REQ_MOVEABSPARAMS | 0x0451 | Host→Device | Request absolute move parameters |
| MGMSG_MOT_GET_MOVEABSPARAMS | 0x0452 | Device→Host | Absolute move parameters |
| MGMSG_MOT_MOVE_ABSOLUTE | 0x0453 | Host→Device | Start absolute move |
| MGMSG_MOT_MOVE_COMPLETED | 0x0464 | Device→Host | Move completed notification |
| MGMSG_MOT_MOVE_VELOCITY | 0x0457 | Host→Device | Start velocity move |
| MGMSG_MOT_MOVE_STOP | 0x0465 | Host→Device | Stop any motion |
| MGMSG_MOT_MOVE_STOPPED | 0x0466 | Device→Host | Motion stopped notification |
### 5.7 Motor Control Messages - Velocity Parameters
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_MOT_SET_VELPARAMS | 0x0413 | Host→Device | Set velocity parameters |
| MGMSG_MOT_REQ_VELPARAMS | 0x0414 | Host→Device | Request velocity parameters |
| MGMSG_MOT_GET_VELPARAMS | 0x0415 | Device→Host | Velocity parameters |
### 5.8 Motor Control Messages - Status
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_MOT_REQ_STATUSUPDATE | 0x0480 | Host→Device | Request status update |
| MGMSG_MOT_GET_STATUSUPDATE | 0x0481 | Device→Host | Status update |
| MGMSG_MOT_REQ_STATUSBITS | 0x0429 | Host→Device | Request status bits |
| MGMSG_MOT_GET_STATUSBITS | 0x042A | Device→Host | Status bits |
### 5.9 BBD-Specific Control Messages
| Message | Hex Code | Direction | Description |
|---------|----------|-----------|-------------|
| MGMSG_MOT_SET_DCPIDPARAMS | 0x04A0 | Host→Device | Set DC motor PID parameters |
| MGMSG_MOT_REQ_DCPIDPARAMS | 0x04A1 | Host→Device | Request DC motor PID parameters |
| MGMSG_MOT_GET_DCPIDPARAMS | 0x04A2 | Device→Host | DC motor PID parameters |
| MGMSG_MOT_SET_POSITIONLOOPPARAMS | 0x04D7 | Host→Device | Set position loop parameters |
| MGMSG_MOT_REQ_POSITIONLOOPPARAMS | 0x04D8 | Host→Device | Request position loop parameters |
| MGMSG_MOT_GET_POSITIONLOOPPARAMS | 0x04D9 | Device→Host | Position loop parameters |
| MGMSG_MOT_SET_MOTOROUTPUTPARAMS | 0x04DA | Host→Device | Set motor output parameters |
| MGMSG_MOT_REQ_MOTOROUTPUTPARAMS | 0x04DB | Host→Device | Request motor output parameters |
| MGMSG_MOT_GET_MOTOROUTPUTPARAMS | 0x04DC | Device→Host | Motor output parameters |
| MGMSG_MOT_SET_TRACKSETTLEDPARAMS | 0x04E0 | Host→Device | Set tracking/settled parameters |
| MGMSG_MOT_REQ_TRACKSETTLEDPARAMS | 0x04E1 | Host→Device | Request tracking/settled parameters |
| MGMSG_MOT_GET_TRACKSETTLEDPARAMS | 0x04E2 | Device→Host | Tracking/settled parameters |
| MGMSG_MOT_SET_PROFILEMODEPARAMS | 0x04E3 | Host→Device | Set profile mode parameters |
| MGMSG_MOT_REQ_PROFILEMODEPARAMS | 0x04E4 | Host→Device | Request profile mode parameters |
| MGMSG_MOT_GET_PROFILEMODEPARAMS | 0x04E5 | Device→Host | Profile mode parameters |
| MGMSG_MOT_SET_JOYSTICKPARAMS | 0x04E6 | Host→Device | Set joystick parameters |
| MGMSG_MOT_REQ_JOYSTICKPARAMS | 0x04E7 | Host→Device | Request joystick parameters |
| MGMSG_MOT_GET_JOYSTICKPARAMS | 0x04E8 | Device→Host | Joystick parameters |
| MGMSG_MOT_SET_CURRENTLOOPPARAMS | 0x04D4 | Host→Device | Set current loop parameters |
| MGMSG_MOT_REQ_CURRENTLOOPPARAMS | 0x04D5 | Host→Device | Request current loop parameters |
| MGMSG_MOT_GET_CURRENTLOOPPARAMS | 0x04D6 | Device→Host | Current loop parameters |
| MGMSG_MOT_SET_SETTLEDCURRENTLOOPPARAMS | 0x04E9 | Host→Device | Set settled current loop parameters |
| MGMSG_MOT_REQ_SETTLEDCURRENTLOOPPARAMS | 0x04EA | Host→Device | Request settled current loop parameters |
| MGMSG_MOT_GET_SETTLEDCURRENTLOOPPARAMS | 0x04EB | Device→Host | Settled current loop parameters |
| MGMSG_MOT_SET_STAGEAXISPARAMS | 0x04F0 | Host→Device | Set stage axis parameters |
| MGMSG_MOT_REQ_STAGEAXISPARAMS | 0x04F1 | Host→Device | Request stage axis parameters |
| MGMSG_MOT_GET_STAGEAXISPARAMS | 0x04F2 | Device→Host | Stage axis parameters |
| MGMSG_MOT_SET_TRIGGER | 0x0500 | Host→Device | Set trigger configuration |
| MGMSG_MOT_REQ_TRIGGER | 0x0501 | Host→Device | Request trigger configuration |
| MGMSG_MOT_GET_TRIGGER | 0x0502 | Device→Host | Trigger configuration |
---
## 6. Command Examples
### 6.1 Enable Channel 1
To enable channel 1 on the BBD203:
**Command bytes:**
```
TX: 10 02 01 01 21 01
```
**Breakdown:**
- `10 02` - MGMSG_MOD_SET_CHANENABLESTATE
- `01` - Enable channel (0x02 to disable)
- `01` - Channel 1
- `21` - Destination (Channel 1)
- `01` - Source (Host PC)
### 6.2 Home Channel 2
To initiate homing on channel 2:
**Command bytes:**
```
TX: 43 04 01 00 22 01
```
**Breakdown:**
- `43 04` - MGMSG_MOT_MOVE_HOME
- `01` - Channel identifier (always 0x01 for BBD203)
- `00` - Not used
- `22` - Destination (Channel 2)
- `01` - Source (Host PC)
### 6.3 Set Position Counter
To set the position counter for channel 1 to 10.0 mm (assuming 20,000 counts/mm):
**Command bytes:**
```
TX: 10 04 06 00 21 01 01 00 40 0D 03 00
```
**Breakdown:**
- `10 04` - MGMSG_MOT_SET_POSCOUNTER
- `06 00` - 6 byte data packet
- `21` - Destination (Channel 1)
- `01` - Source (Host PC)
- `01 00` - Channel 1 (in data packet)
- `40 0D 03 00` - Position = 200,000 counts (10 mm × 20,000)
### 6.4 Move Absolute
To move channel 3 to absolute position 50 mm:
**Command bytes:**
```
TX: 53 04 06 00 23 01 01 00 A0 86 01 00
```
**Breakdown:**
- `53 04` - MGMSG_MOT_MOVE_ABSOLUTE
- `06 00` - 6 byte data packet
- `23` - Destination (Channel 3)
- `01` - Source (Host PC)
- `01 00` - Channel 1 (in data packet, always 0x01 0x00)
- `A0 86 01 00` - Position = 1,000,000 counts (50 mm × 20,000)
### 6.5 Set Velocity Parameters
To set velocity parameters for channel 2 (max velocity = 5 mm/s, acceleration = 10 mm/s²):
**Command bytes:**
```
TX: 13 04 0E 00 22 01 01 00 00 00 8A 44 0A 00 89 00 00 00
```
**Breakdown:**
- `13 04` - MGMSG_MOT_SET_VELPARAMS
- `0E 00` - 14 byte data packet
- `22` - Destination (Channel 2)
- `01` - Source (Host PC)
- `01 00` - Channel 1 (in data packet)
- `00 00` - Min velocity (usually 0)
- `8A 44 0A 00` - Max velocity = 671,090 APT units (5 mm/s)
- `89 00 00 00` - Acceleration = 137 APT units (10 mm/s²)
### 6.6 Stop Motion
To immediately stop motion on all channels:
**Command bytes:**
```
TX: 65 04 01 01 11 01
```
**Breakdown:**
- `65 04` - MGMSG_MOT_MOVE_STOP
- `01` - Channel identifier
- `01` - Stop mode (0x01 = immediate, 0x02 = profiled)
- `11` - Destination (All channels)
- `01` - Source (Host PC)
### 6.7 Request Status Update
To request a status update from channel 1:
**Command bytes:**
```
TX: 80 04 01 00 21 01
```
**Breakdown:**
- `80 04` - MGMSG_MOT_REQ_STATUSUPDATE
- `01` - Channel identifier
- `00` - Not used
- `21` - Destination (Channel 1)
- `01` - Source (Host PC)
**Response format (20 bytes):**
```
RX: 81 04 14 00 01 00 [Channel] [Position-4bytes] [EncCount-4bytes] [StatusBits-4bytes]
```
### 6.8 Set Position Loop Parameters
To set position loop PID parameters for channel 1:
**Command bytes:**
```
TX: D7 04 1C 00 21 01 01 00 41 00 AF 00 80 38 01 00 [12 more bytes...]
```
**Data packet structure:**
- Bytes 0-1: Channel (0x01 0x00)
- Bytes 2-3: Proportional gain
- Bytes 4-5: Integral gain
- Bytes 6-9: Integral limit
- Bytes 10-13: Derivative gain
- Bytes 14-15: Derivative time
- Bytes 16-17: Loop rate
- Bytes 18-19: Output gain
- Bytes 20-23: Velocity feedforward gain
- Bytes 24-25: Acceleration feedforward gain
- Bytes 26-27: Position error limit
---
## 7. Important Notes for BBD203
### 7.1 Digital Output Configuration
On the BBD203, the digital output and trigger output share a common pin. Before using the digital output functionality, the trigger functionality must be disabled by calling the `MGMSG_MOT_SET_TRIGGER` message with appropriate parameters.
**To disable trigger and enable digital output:**
```
TX: 00 05 06 00 21 01 01 00 00 00 00 00
```
### 7.2 Multi-Channel Operation
Although the BBD203 has three channels, each channel operates as an independent single-channel controller. Important points:
- Always use Channel 1 (`0x01 0x00`) in the channel identifier field of data packets
- Use the destination byte (`0x21`, `0x22`, or `0x23`) in the header to specify the physical channel
- Each channel maintains its own parameters and status independently
### 7.3 Encoder Scaling
All position values must be scaled according to the encoder counts per unit of your specific motor and stage combination. Common encoder resolutions:
| Stage Type | Encoder Counts/mm | Notes |
|------------|-------------------|-------|
| MLS203 | 20,000 | Standard linear stage |
| DDS220 | 2,000 | Direct drive stage |
| Custom | Varies | Check motor specification |
### 7.4 Status Updates
After connecting to the BBD203, it is important to:
1. Call `MGMSG_HW_START_UPDATEMSGS` to enable automatic status updates
2. This ensures move completed and other status messages are received properly
3. Status updates can be disabled with `MGMSG_HW_STOP_UPDATEMSGS` when not needed
**Enable status updates:**
```
TX: 11 00 00 00 11 01
```
### 7.5 Error Handling
The BBD203 returns error messages via `MGMSG_HW_RESPONSE` (0x0080) or `MGMSG_HW_RICHRESPONSE` (0x0081). Common error conditions:
| Error | Description |
|-------|------------|
| Over current | Motor drawing excessive current |
| Following error | Position error exceeds limit |
| Limit switch | Hardware limit reached |
| Not homed | Attempting move before homing |
### 7.6 Trigger Configuration
The BBD203 supports hardware triggering for synchronized motion. Trigger modes:
| Mode | Value | Description |
|------|-------|------------|
| Disabled | 0x00 | No triggering |
| In/Out Relative Move | 0x01 | Trigger initiates relative move |
| In/Out Absolute Move | 0x02 | Trigger initiates absolute move |
| In/Out Home | 0x03 | Trigger initiates homing |
| In/Out Stop | 0x04 | Trigger stops motion |
| Out Only | 0x10 | Generate trigger output on move |
| Out Position | 0x11 | Trigger at specific position |
### 7.7 Profile Modes
The BBD203 supports different motion profile modes:
| Mode | Value | Description |
|------|-------|------------|
| Trapezoidal | 0x00 | Linear acceleration/deceleration |
| S-Curve | 0x02 | Smooth acceleration with jerk limiting |
### 7.8 Communication Best Practices
1. **Initialization Sequence:**
- Send `MGMSG_HW_REQ_INFO` to verify connection
- Enable required channels with `MGMSG_MOD_SET_CHANENABLESTATE`
- Start update messages with `MGMSG_HW_START_UPDATEMSGS`
- Home axes if required
2. **Movement Sequence:**
- Set velocity/acceleration parameters
- Clear any errors
- Send move command
- Wait for move completed message
3. **Shutdown Sequence:**
- Stop any motion with `MGMSG_MOT_MOVE_STOP`
- Disable channels if needed
- Send `MGMSG_HW_DISCONNECT` before closing port
---
## Additional Information
This document contains only the essential information for controlling the BBD203 motor controller. For complete protocol details, advanced features, and other Thorlabs motion control products, please refer to the full APT Communications Protocol documentation.
### Contact Information
**Thorlabs, Inc.**
- Website: www.thorlabs.com
- Technical Support: techsupport@thorlabs.com
---
*Document generated from Thorlabs APT Communications Protocol v42.1*
## Table of Contents
1. [Introduction](#1-introduction)
2. [General Protocol Information](#2-general-protocol-information)
3. [BBD203 Specifications](#3-bbd203-specifications)
4. [Message Format](#4-message-format)
5. [BBD203 Applicable Messages](#5-bbd203-applicable-messages)
6. [Command Examples](#6-command-examples)
7. [Important Notes](#7-important-notes)
---
## 1. Introduction
The BBD203 is a 3-channel benchtop brushless DC motor controller that is part of Thorlabs' APT motion control system. This document describes the communication protocol used to control the BBD203 via USB or RS232 interfaces.
### 1.1 Device Overview
The BBD203 provides independent control of up to three brushless DC motors with the following key features:
- 3 independent motor channels
- USB and RS232 communication interfaces
- Closed-loop position and velocity control
- Encoder feedback support
- Digital I/O for triggering and synchronization
- Compatible with Thorlabs' APT software suite
### 1.2 Device Information
- **Product Name**: BBD203 - 3 Channel Benchtop Brushless DC Motor Driver
- **Protocol Version**: 42.1
- **Communication**: Binary message protocol over USB/RS232
---
## 2. General Protocol Information
### 2.1 Communication Format
All communications with the BBD203 use a binary message protocol. Messages consist of a 6-byte header followed by an optional data packet.
#### Header Structure
| Byte | Description |
|------|-------------|
| 0-1 | Message ID (16-bit, little-endian) |
| 2 | Data length (bytes) or parameter 1 |
| 3 | Data length MSB or parameter 2 |
| 4 | Destination |
| 5 | Source |
#### Destination Byte Values
| Value | Description |
|-------|-------------|
| 0x50 | USB interface |
| 0x11 | All channels (unit) |
| 0x21 | Channel 1 (Bay 1) |
| 0x22 | Channel 2 (Bay 2) |
| 0x23 | Channel 3 (Bay 3) |
#### Source Byte Values
| Value | Description |
|-------|-------------|
| 0x01 | Host PC |
### 2.2 Channel Addressing
The BBD203 has three motor channels. When addressing specific channels:
- **Channel 1**: Use destination byte `0x21`
- **Channel 2**: Use destination byte `0x22`
- **Channel 3**: Use destination byte `0x23`
- **All channels**: Use destination byte `0x11`
**Important**: Although the BBD203 has three channels, each channel operates as an independent single-channel controller. In the data packet's channel identifier field, always use Channel 1 (0x01), and specify the physical channel using the destination byte in the header.
### 2.3 Data Types
| Type | Size | Description |
|------|------|-------------|
| char | 1 byte | 8-bit signed integer |
| short | 2 bytes | 16-bit signed integer |
| long | 4 bytes | 32-bit signed integer |
| word | 2 bytes | 16-bit unsigned integer |
| dword | 4 bytes | 32-bit unsigned integer |
All multi-byte values are transmitted in little-endian format.
---
## 3. BBD203 Specifications
### 3.1 Encoder and Position Scaling
For BBD203 controllers, position and velocity values are scaled based on encoder counts. The scaling depends on your specific motor and stage combination.
#### Position Scaling
```
POSAPT = EncCnt × Pos
```
Where:
- `POSAPT` = Position value to send/receive via APT protocol
- `EncCnt` = Encoder counts per unit (e.g., counts per mm)
- `Pos` = Actual position in real units
#### Velocity Scaling
```
VELAPT = EncCnt × T × 65536 × Vel
```
Where:
- `VELAPT` = Velocity value to send/receive via APT protocol
- `EncCnt` = Encoder counts per unit
- `T` = 102.4 × 10⁻⁶
- `Vel` = Actual velocity in real units per second
#### Acceleration Scaling
```
ACCAPT = EncCnt × T² × 65536 × Acc
```
Where:
- `ACCAPT` = Acceleration value to send/receive via APT protocol
- `EncCnt` = Encoder counts per unit
- `T` = 102.4 × 10⁻⁶
- `Acc` = Actual acceleration in real units per second²
### 3.2 Example Scaling Values
For a stage with 20,000 encoder counts per mm:
| Parameter | Real Value | APT Value |
|-----------|------------|-----------|
| Position | 10 mm | 200,000 |
| Position | 50 mm | 1,000,000 |
| Velocity | 1 mm/s | 134.218 |
| Acceleration | 1 mm/s² | 0.0137 |
---
## 4. Message Format
### 4.1 Message Categories
Messages are organized into the following categories:
- **MOD** - Module control messages (identify, enable/disable)
- **HW** - Hardware information and control
- **MOT** - Motor control messages (move, velocity, position)
- **RACK** - Rack and bay status messages
### 4.2 Message Direction
- **SET** - Host sends command with parameters to controller
- **REQ
+390
View File
@@ -0,0 +1,390 @@
# ThorLabs BBD203 Motor Controller Driver
## Overview
This directory contains a complete implementation of the ThorLabs BBD203 3-channel benchtop brushless DC motor controller driver. The driver implements the full APT (Advanced Positioning Technology) binary communications protocol as specified in the BBD203_Communications_Protocol.md document.
## Architecture
The BBD203 driver is split into three layers:
### 1. Protocol Layer (`bbd203_protocol.py`)
Low-level protocol implementation that handles:
- Binary message construction and parsing
- APT protocol message IDs and structures
- Unit conversions (mm ↔ encoder counts, velocity/acceleration scaling)
- Status bit definitions
**Key Classes:**
- `MessageID`: Enumeration of all APT message IDs
- `APTMessage`: Message builder and parser for binary protocol
- `APTProtocol`: High-level protocol interface with unit conversions
### 2. Driver Layer (`bbd203_driver.py`)
Complete driver implementation providing:
- Serial communication with automatic message reception thread
- 3-channel management (independent motor control)
- Blocking and non-blocking move operations
- Status monitoring with automatic updates
- Event callbacks for move/home completion
- Thread-safe operation
**Key Classes:**
- `BBD203Channel`: Represents state of a single motor channel
- `BBD203Driver`: Main driver class for controller communication
### 3. Stage Interface Layer (`thorlabs_stage.py`)
Application-specific wrapper that:
- Maps 3 motor channels to X/Y/Z axes
- Provides simplified API for stage control
- Integrates with the nueScan application
- Maintains compatibility with existing UI
**Channel Mapping:**
- Channel 1 → X-axis
- Channel 2 → Y-axis
- Channel 3 → Z-axis (optional)
## Features
### Communication
- Binary APT protocol over USB/RS232
- Baud rate: 115200 (configurable)
- Automatic message reception in background thread
- Command/response handling with proper timeout
### Motion Control
- Absolute positioning
- Relative moves
- Velocity control
- Immediate and profiled stops
- Configurable acceleration
### Position Feedback
- Real-time position updates (encoder counts)
- Position in mm (with configurable scaling)
- Status bit monitoring (homing, moving, errors, etc.)
### Homing
- Individual axis homing
- All-axes homing
- Blocking or non-blocking operation
- Completion callbacks
### Safety
- Interlock checking before moves
- Error detection and reporting
- Motion error monitoring
- Limit switch status
## Usage
### Connection Methods
The driver supports two connection methods:
#### Method 1: Connect by Serial Number (Recommended)
Similar to ThorLabs Kinesis library - automatically finds the USB device:
```python
from hardware.bbd203_driver import BBD203Driver
# Create driver instance
driver = BBD203Driver(encoder_counts_per_mm=20000)
# List available ThorLabs devices
devices = driver.list_thorlabs_devices()
for device in devices:
print(f"Serial: {device['serial']}, Port: {device['port']}")
# Connect by serial number (auto-finds the port)
driver.connect_by_serial('83123456') # Serial printed on controller
```
#### Method 2: Connect by Port Name
Direct connection to a specific port:
```python
# Connect to specific port
driver.connect('/dev/ttyUSB0') # or 'COM3' on Windows
```
### Basic Movement
```python
# Enable all channels
driver.enable_channel(1, True) # X-axis
driver.enable_channel(2, True) # Y-axis
driver.enable_channel(3, True) # Z-axis
# Home all channels (blocking)
driver.home_all_channels(wait=True, timeout=60)
# Set velocity parameters
driver.set_velocity_params(channel=1, max_vel_mm_s=5.0, accel_mm_s2=10.0)
# Move to absolute position (non-blocking)
driver.move_absolute(channel=1, position_mm=10.0, wait=False)
# Move to absolute position (blocking)
driver.move_absolute(channel=2, position_mm=25.0, wait=True, timeout=30)
# Move relative
driver.move_relative(channel=1, distance_mm=-5.0, wait=True)
# Stop motion
driver.stop(channel=1, immediate=True)
# Get position
pos = driver.get_position(channel=1)
print(f"Position: {pos} mm")
# Get detailed status
status = driver.get_channel_status(channel=1)
print(f"Enabled: {status['enabled']}")
print(f"Homed: {status['homed']}")
print(f"Moving: {status['moving']}")
# Disconnect
driver.disconnect()
```
### Using the Stage Interface
```python
from hardware.thorlabs_stage import ThorLabsStage
# Create stage controller
stage = ThorLabsStage(encoder_counts_per_mm=20000)
# List available devices
devices = stage.list_devices()
for device in devices:
print(f"BBD203 Serial: {device['serial']}")
# Connect by serial number (automatically enables all channels)
stage.connect('83123456') # Serial number from controller label
# Home all axes
stage.home_all_axes(wait=True)
# Move to position
stage.move_absolute(x=10.0, y=20.0, wait=True)
# Move relative
stage.move_relative(dx=5.0, dy=-2.5, wait=True)
# Get position
pos = stage.get_position()
print(f"X: {pos['x']} mm, Y: {pos['y']} mm, Z: {pos['z']} mm")
# Check status
status = stage.get_status()
print(f"Ready: {status['ready']}")
print(f"X Homed: {status['x_homed']}")
# Disconnect
stage.disconnect()
```
### Event Callbacks
```python
# Define callback function
def on_move_complete(channel):
print(f"Channel {channel} move completed!")
# Register callback
driver.register_move_complete_callback(1, on_move_complete)
# Start non-blocking move - callback will be called when complete
driver.move_absolute(channel=1, position_mm=50.0, wait=False)
```
## Configuration
### Encoder Scaling
The encoder resolution must be configured to match your specific motor/stage combination:
```python
# Example: MLS203 stage with 20,000 counts/mm
driver = BBD203Driver(encoder_counts_per_mm=20000)
# Example: Custom stage with 2,000 counts/mm
driver = BBD203Driver(encoder_counts_per_mm=2000)
```
Common encoder resolutions:
- **MLS203**: 20,000 counts/mm
- **DDS220**: 2,000 counts/mm
- **Custom**: Varies (check motor specifications)
### Velocity and Acceleration
Velocity and acceleration use the APT scaling formulas:
```
VEL_APT = EncCnt × 102.4e-6 × 65536 × Vel
ACC_APT = EncCnt × (102.4e-6)² × 65536 × Acc
```
The driver handles these conversions automatically:
```python
# Set velocity to 5 mm/s, acceleration to 10 mm/s²
driver.set_velocity_params(
channel=1,
max_vel_mm_s=5.0,
accel_mm_s2=10.0
)
```
## Integration with nueScan
The driver is integrated into nueScan through the `ThorLabsStage` wrapper class. The connection is simplified using serial number auto-detection:
### Connecting in the UI
1. **Find Serial Number**: Look at the label on your BBD203 controller (e.g., `83123456`)
2. **Enter Serial**: Type the serial number in the "ThorLABS MLS Stage Serial" field
3. **Connect**: Click "Connect" button
- Driver automatically finds the USB device
- All 3 channels are enabled
- Status updates begin
4. **Ready**: The controller is now ready to home and move axes
### Connection Process
When you click "Connect":
- The driver scans all USB ports for ThorLabs devices (FTDI VID: 0x0403)
- Finds the device matching your serial number
- Automatically uses the correct COM port
- Enables all channels (X/Y/Z axes)
- Sets default velocity parameters
### Troubleshooting Connection
If connection fails, a dialog will show:
- The serial number you entered
- List of all detected ThorLabs devices with their serial numbers
- Helps you identify the correct serial to use
## Protocol Details
### Message Structure
All APT messages consist of:
- 6-byte header (message ID, length, destination, source)
- Optional data packet (variable length)
### Destination Addressing
- `0x21`: Channel 1 (X-axis)
- `0x22`: Channel 2 (Y-axis)
- `0x23`: Channel 3 (Z-axis)
- `0x11`: All channels
- `0x50`: USB interface
### Status Bits
Key status bits monitored by the driver:
| Bit | Mask | Meaning |
|-----|------|---------|
| HOMING | 0x00000200 | Homing in progress |
| HOMED | 0x00000400 | Axis has been homed |
| TRACKING | 0x00001000 | Following target position |
| SETTLED | 0x00002000 | Position settled |
| MOTION_ERROR | 0x00004000 | Following error exceeded |
| MOTOR_ENABLED | 0x80000000 | Motor drive enabled |
| IN_MOTION_FORWARD | 0x00000010 | Moving forward |
| IN_MOTION_REVERSE | 0x00000020 | Moving reverse |
## Troubleshooting
### Connection Issues
**Problem:** Cannot connect to controller
**Solutions:**
- Verify COM port name is correct (`ThorLabsStage.list_available_ports()`)
- Check USB cable connection
- Verify no other software has the port open
- Try different baud rate (default: 115200)
- Check device permissions on Linux
### Homing Fails
**Problem:** Homing timeout or never completes
**Solutions:**
- Increase homing timeout parameter
- Check limit switches are functioning
- Verify motor is enabled
- Check for mechanical obstructions
- Review homing parameters (direction, velocity)
### Position Errors
**Problem:** Reported position doesn't match reality
**Solutions:**
- Verify `encoder_counts_per_mm` setting matches your stage
- Check encoder connections
- Reset position counter if needed: `driver.cmd_set_position_counter()`
- Verify stage is homed before moves
### Communication Errors
**Problem:** Commands not acknowledged or responses missing
**Solutions:**
- Increase serial timeout
- Check for message buffer overflow
- Verify automatic status updates are enabled
- Add delays between rapid commands
## Debug Output
The driver provides extensive debug output:
```
INFO: Messages about successful operations
DEBUG: Detailed command/response information
ERROR: Error conditions and failures
WARNING: Potential issues
```
Enable Python logging to capture all output:
```python
import logging
logging.basicConfig(level=logging.DEBUG)
```
## Performance Notes
- Message processing runs in separate thread (no blocking)
- Typical command response time: 10-50ms
- Position updates: ~10Hz when status messages enabled
- Move completion detected via asynchronous message
- Thread-safe for concurrent channel operations
## References
- **Protocol Documentation**: `BBD203_Communications_Protocol.md`
- **APT Protocol Version**: 42.1
- **Product Manual**: Available from Thorlabs.com
- **Technical Support**: techsupport@thorlabs.com
## License
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
+596
View File
@@ -0,0 +1,596 @@
# Helios Laser System Driver
## Overview
This directory contains a complete implementation of the Helios laser system driver. The driver implements the full RS-232 ASCII communications protocol as specified in the helios_comms_protocol.pdf document.
The Helios laser system is a pulsed solid-state laser with:
- Diode-pumped Nd:YAG/Nd:YLF laser head
- Q-switched operation
- Frequency control (16.7 kHz - 125 kHz)
- Current control (0-7000 mA)
- Power monitoring
- Temperature monitoring (4 sensors)
- External trigger capability
## Architecture
The Helios driver is split into three layers:
### 1. Protocol Layer (`helios_protocol.py`)
Low-level protocol implementation that handles:
- ASCII command construction
- Response parsing and validation
- Unit conversions (Hz ↔ ns, °C ↔ m°C)
- Parameter range validation
- Status register decoding
**Key Classes:**
- `HeliosCommand`: Command constants and builders
- `HeliosProtocol`: High-level protocol interface with validation
### 2. Driver Layer (`helios_driver.py`)
Complete driver implementation providing:
- RS-232 serial communication (9600 baud, 8N1)
- Thread-safe command/query operations
- Comprehensive status monitoring
- Temperature monitoring (pump, resonator, q-switch, power stage)
- Power monitoring
- Laser enable/disable control
- Pulse mode control (single, gating, continuous)
- Frequency and current control
**Key Classes:**
- `PulseMode`: Enumeration of pulse modes
- `HeliosStatus`: Status data structure
- `HeliosDriver`: Main driver class for laser communication
### 3. Integration Layer (`hardware/microscope.py`)
Application-specific integration that:
- Combines Helios with Genesis microscope systems
- Provides unified status monitoring
- Integrates with nueScan application
- Implements safety interlocks
- Provides emergency stop functionality
## Features
### Communication
- ASCII-based RS-232 protocol
- Baud rate: 9600, 8 data bits, no parity, 1 stop bit
- Commands terminated with carriage return (CR)
- Thread-safe operation with mutex locking
- Configurable timeout (default: 1 second)
### Laser Control
- Laser enable/disable
- Three pulse modes:
- Single pulse (one pulse per trigger)
- Continuous gating (pulse train while triggered)
- Continuous pulsing (free-running)
- Frequency control (16.7 kHz to 125 kHz)
- Diode current control (0-7000 mA)
### Monitoring
- Real-time power measurement (mW)
- Four temperature sensors:
- Pump diode temperature
- Resonator temperature
- Q-switch temperature
- Power stage temperature
- Operation hours counter
- Comprehensive status register
- Error detection
### Safety
- Temperature monitoring with warnings
- Error status detection
- Laser enable/disable control
- Emergency stop capability
- Integration with system interlocks
## Usage
### Connection Methods
The driver supports connection to a specific COM port:
```python
from hardware.helios_driver import HeliosDriver
# Create driver instance
driver = HeliosDriver(timeout=1.0)
# List available COM ports
ports = HeliosDriver.list_available_ports()
for port in ports:
print(f"Available port: {port}")
# Connect to specific port
driver.connect('COM5') # or '/dev/ttyUSB0' on Linux
# Get device information
print(f"Controller S/N: {driver.get_controller_serial()}")
print(f"Head S/N: {driver.get_head_serial()}")
```
### Basic Laser Control
```python
# Set frequency (in Hz)
driver.set_frequency_hz(10000) # 10 kHz
# Set diode current (in mA)
driver.set_current_ma(500) # 500 mA
# Set pulse mode
from hardware.helios_driver import PulseMode
driver.set_pulse_mode(PulseMode.CONTINUOUS_PULSING)
# Enable laser
driver.set_laser_enable(True)
# Check if laser is enabled
if driver.is_laser_enabled():
print("Laser is ON")
# Disable laser
driver.set_laser_enable(False)
# Disconnect
driver.disconnect()
```
### Monitoring
```python
# Get current power
power_mw = driver.get_power_mw()
print(f"Output power: {power_mw} mW")
# Get temperatures (in Celsius)
temps = driver.get_all_temperatures()
print(f"Pump: {temps['pump_temp_c']:.1f}°C")
print(f"Resonator: {temps['resonator_temp_c']:.1f}°C")
print(f"Q-switch: {temps['qswitch_temp_c']:.1f}°C")
print(f"Power stage: {temps['power_stage_temp_c']:.1f}°C")
# Get operation hours
hours = driver.get_operation_hours()
print(f"Operation time: {hours} hours")
# Get comprehensive status
status = driver.get_status()
print(f"Connected: {status['connected']}")
print(f"Laser enabled: {status['laser_enabled']}")
print(f"Frequency: {status['frequency_hz']} Hz")
print(f"Current: {status['current_ma']} mA")
print(f"Power: {status['power_mw']} mW")
print(f"Has errors: {status['has_errors']}")
```
### Status Updates
```python
# Manually update status from hardware
driver.update_status()
# Status is automatically updated on each get_status() call
status = driver.get_status()
# Access cached values without querying hardware
freq = driver.get_frequency_hz() # Returns last read value
current = driver.get_current_ma() # Returns last read value
```
### Using Through Microscope Controller
The Helios driver is integrated into the application through the `MicroscopeController`:
```python
from hardware.microscope import MicroscopeController
# Create controller
microscope = MicroscopeController()
# Connect Helios
microscope.connect_helios('COM5')
# Apply settings from dialog
settings = {
'com_port': 'COM5',
'frequency_hz': 10000,
'current_ma': 500
}
microscope.apply_helios_settings(settings)
# Enable laser
microscope.helios_enable_laser(True)
# Get status
status = microscope.get_helios_status()
print(f"Helios ready: {status['ready']}")
print(f"Power: {status['power_mw']} mW")
# Disable laser
microscope.helios_enable_laser(False)
# Disconnect
microscope.disconnect_helios()
```
## Configuration
### Frequency Control
The Helios laser operates by setting the pulse period in nanoseconds. The driver automatically converts between frequency (Hz) and period (ns):
```python
# Set frequency in Hz (driver converts to period in ns)
driver.set_frequency_hz(10000) # 10 kHz → 100,000 ns period
# Valid frequency range: 16.7 kHz to 125 kHz
# Valid period range: 8000 ns to 60000 ns
```
Conversion formulas:
```
Period (ns) = 1,000,000,000 / Frequency (Hz)
Frequency (Hz) = 1,000,000,000 / Period (ns)
```
### Current Control
The diode current controls the laser output power:
```python
# Set current in milliamps
driver.set_current_ma(500) # 500 mA
# Valid range: 0 to 7000 mA
```
**Important:** Higher currents produce more power but also more heat. Monitor temperatures when operating at high current.
### Pulse Modes
Three pulse modes are available:
```python
from hardware.helios_driver import PulseMode
# Single pulse mode (one pulse per trigger)
driver.set_pulse_mode(PulseMode.SINGLE_PULSE)
# Continuous gating mode (pulse train while triggered)
driver.set_pulse_mode(PulseMode.CONTINUOUS_GATING)
# Continuous pulsing mode (free-running)
driver.set_pulse_mode(PulseMode.CONTINUOUS_PULSING)
```
**Mode Descriptions:**
- **Single Pulse (LDG=0)**: One pulse generated per external trigger
- **Continuous Gating (LDG=1)**: Pulse train while external trigger is high
- **Continuous Pulsing (LDG=2)**: Free-running at set frequency (default)
### Temperature Monitoring
The driver monitors four temperature sensors:
```python
# Individual temperatures
pump_temp = driver.query_pump_temp_c()
resonator_temp = driver.query_resonator_temp_c()
qswitch_temp = driver.query_qswitch_temp_c()
power_stage_temp = driver.query_power_stage_temp_c()
# All temperatures at once
temps = driver.get_all_temperatures()
```
**Temperature Ranges:**
- Normal operation: < 50°C
- Warning threshold: > 60°C
- Critical threshold: > 70°C
## Integration with nueScan
The Helios driver is integrated into nueScan through the settings dialog and microscope controller.
### Configuration in UI
1. **Open Helios Settings**
- Click "Helios Device Settings" button in main window
2. **Configure Parameters**
- **COM Port**: Select from dropdown (automatically populated)
- **Frequency**: Enter in Hz (16,666 - 125,000 Hz)
- **Current**: Enter in mA (0 - 7000 mA)
3. **Apply Settings**
- Click OK to apply and connect
- Settings are validated before sending to hardware
### Settings Dialog Integration
The `HeliosDialog` class provides:
- Automatic COM port enumeration
- Input validation with range checking
- User-friendly error messages
- Settings persistence
```python
# Dialog usage (called from main window)
from dialogs.helios_dialog import HeliosDialog
dialog = HeliosDialog(parent=self)
if dialog.exec() == QDialog.DialogCode.Accepted:
settings = dialog.get_settings() # Returns None if validation fails
if settings:
self.microscope.apply_helios_settings(settings)
```
### Validation Rules
The dialog validates all inputs before accepting:
**Frequency Validation:**
- Range: 16,666 Hz to 125,000 Hz
- Reason: Hardware period limit of 8000-60000 ns
- Error message shows entered value and valid range
**Current Validation:**
- Range: 0 to 7000 mA
- Reason: Maximum diode current rating
- Error message shows entered value and valid range
**COM Port Validation:**
- Must select valid port from list
- Cannot accept "No ports found" placeholder
- Error message prompts to check connections
## Protocol Details
### Command Format
All commands follow the format:
```
COMMAND [value]<CR>
```
Where:
- `COMMAND` is a 3-letter mnemonic (e.g., LDO, LDF, LDS)
- `[value]` is optional numeric parameter
- `<CR>` is carriage return (0x0D)
### Command Set
| Command | Parameter | Description |
|---------|-----------|-------------|
| LDO | 0/1 | Laser enable (0=off, 1=on) |
| LDG | 0/1/2 | Pulse mode (0=single, 1=gating, 2=continuous) |
| LDF | 8000-60000 | Pulse period in nanoseconds |
| LDS | 0-7000 | Diode current in milliamps |
| LDP | - | Query output power (mW) |
| LDPT | - | Query pump temperature (m°C) |
| LDRT | - | Query resonator temperature (m°C) |
| LDQT | - | Query q-switch temperature (m°C) |
| LDPST | - | Query power stage temperature (m°C) |
| LDSR | - | Query status register |
| LDOH | - | Query operation hours |
| LDCSN | - | Query controller serial number |
| LDHSN | - | Query head serial number |
### Response Format
Responses are numeric values terminated with `<CR>`:
```
12345<CR>
```
**Exception:** Serial numbers are returned as strings:
```
SN12345678<CR>
```
### Status Register
The status register (LDSR) returns a 16-bit value with error flags:
| Bit | Mask | Meaning |
|-----|------|---------|
| 0 | 0x0001 | Pump temperature error |
| 1 | 0x0002 | Resonator temperature error |
| 2 | 0x0004 | Q-switch temperature error |
| 3 | 0x0008 | Power stage temperature error |
| 4 | 0x0010 | Diode current error |
| 5 | 0x0020 | Interlock open |
| 6 | 0x0040 | Over-power condition |
| 7 | 0x0080 | Under-voltage condition |
A status of 0 indicates no errors.
### Set and Verify Pattern
For critical parameters, the driver uses a set-and-verify pattern:
```python
def _set_and_verify(self, set_cmd: bytes, query_cmd: bytes, expected: str) -> bool:
# Send set command
self._serial.write(set_cmd)
time.sleep(0.05) # Allow hardware to process
# Query back the value
self._serial.write(query_cmd)
response = self._read_response()
# Verify it matches
return response.strip() == expected.strip()
```
This ensures commands are executed correctly and hardware state matches software state.
## Troubleshooting
### Connection Issues
**Problem:** Cannot connect to laser
**Solutions:**
- Verify COM port name is correct (`HeliosDriver.list_available_ports()`)
- Check RS-232 cable connection
- Verify laser controller is powered on
- Try different COM port
- Check device permissions on Linux (`sudo usermod -a -G dialout $USER`)
### Communication Errors
**Problem:** Commands fail or no response
**Solutions:**
- Verify baud rate is 9600 (default)
- Check cable for proper null-modem configuration if needed
- Increase timeout: `driver = HeliosDriver(timeout=2.0)`
- Check for CR line termination (0x0D)
- Verify no other software has port open
### Frequency/Current Not Updating
**Problem:** Settings don't change on hardware
**Solutions:**
- Check return value of `set_frequency_hz()` and `set_current_ma()`
- Verify parameters are in valid range
- Check status register for errors: `driver.query_status_register()`
- Ensure laser is not in error state
- Try power cycling the controller
### Temperature Warnings
**Problem:** High temperature readings
**Solutions:**
- Check ventilation around laser head and controller
- Reduce diode current if at maximum
- Allow longer cool-down between operations
- Clean air filters if present
- Check for blocked cooling fans
### Laser Won't Enable
**Problem:** `set_laser_enable(True)` fails or laser stays off
**Solutions:**
- Check interlock connections (bit 5 of status register)
- Verify all interlocks are closed
- Check for error flags in status register
- Ensure parameters (frequency, current) are set
- Check external enable switch if present
- Review safety interlock documentation
### Status Register Errors
**Problem:** Status register shows error bits set
**Solutions:**
- Decode status register: `HeliosProtocol.decode_status_register(value)`
- Address specific error conditions:
- Temperature errors: Improve cooling
- Current error: Reduce current setting
- Interlock open: Check safety connections
- Over-power: Reduce current
- Under-voltage: Check power supply
## Debug Output
The driver provides extensive debug output:
```
INFO: Informational messages about operations
DEBUG: Detailed command/response information
WARNING: Potential issues (high temp, errors)
ERROR: Operation failures
```
Enable Python logging to capture all output:
```python
import logging
logging.basicConfig(level=logging.DEBUG)
```
Example debug output:
```
INFO: Connecting to Helios laser on COM5
DEBUG: Sending command: b'LDCSN\r'
DEBUG: Received response: SN12345678
INFO: Successfully connected to Helios on COM5
DEBUG: Sending command: b'LDF 100000\r'
DEBUG: Verifying frequency setting...
INFO: Frequency set to 10000.0 Hz (period: 100000 ns)
```
## Performance Notes
- Command response time: 50-100ms typical
- Temperature queries: ~100ms per sensor
- Status register query: ~50ms
- All queries are synchronous (blocking)
- Thread-safe for concurrent access (mutex protected)
- Set-and-verify adds ~50ms overhead for reliability
## Safety Considerations
### Laser Safety
- **Class 4 Laser**: Hazardous to eyes and skin
- Always verify laser is disabled before opening beam paths
- Use appropriate laser safety eyewear
- Follow all facility laser safety procedures
- Ensure proper interlock connections
### Thermal Management
- Monitor temperatures during operation
- Allow adequate cool-down between high-power operations
- Ensure proper ventilation
- Do not block cooling vents
### Electrical Safety
- Verify proper grounding
- Use shielded cables for trigger/status connections
- Follow proper ESD procedures when servicing
## Hardware Connections
### Utility Connector (9-pin D-Sub)
The utility connector provides external control:
| Pin | Signal | Description |
|-----|--------|-------------|
| 1 | GND | Ground |
| 2 | Laser Disable | Input: Pull low to disable laser |
| 3 | External Trigger | Input: Rising edge triggers pulse |
| 4 | Status Out | Output: High when ready |
| 5 | GND | Ground |
| 6-9 | NC | Not connected |
Trigger specifications:
- Input: TTL/CMOS compatible
- Minimum pulse width: 100ns
- Maximum frequency: Limited by pulse mode setting
## References
- **Protocol Documentation**: `helios_comms_protocol.pdf`
- **RS-232 Standard**: EIA/TIA-232
- **Integration Guide**: `SETUP.md`
- **Connection Guide**: See main window Helios settings dialog
## License
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+2
View File
@@ -0,0 +1,2 @@
# nuescan
SRAS Scan Planning and Control Software
+161
View File
@@ -0,0 +1,161 @@
# 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
@@ -0,0 +1,30 @@
#!/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()
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
"""
Dialog controllers for nueScan application
"""
+71
View File
@@ -0,0 +1,71 @@
"""
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
@@ -0,0 +1,169 @@
"""
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
@@ -0,0 +1,126 @@
"""
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
@@ -0,0 +1,163 @@
"""
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
@@ -0,0 +1,101 @@
"""
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
@@ -0,0 +1,4 @@
"""
Hardware communication modules for nueScan
Handles communication with ThorLabs stage, T3R device, and microscopes
"""
+928
View File
@@ -0,0 +1,928 @@
"""
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
@@ -0,0 +1,532 @@
"""
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
@@ -0,0 +1,627 @@
"""
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
@@ -0,0 +1,302 @@
"""
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
@@ -0,0 +1,387 @@
"""
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
@@ -0,0 +1,244 @@
"""
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
@@ -0,0 +1,259 @@
"""
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
@@ -0,0 +1,651 @@
"""
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()
}
BIN
View File
Binary file not shown.
+411
View File
@@ -0,0 +1,411 @@
"""
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
@@ -0,0 +1,78 @@
<?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
@@ -0,0 +1,107 @@
<?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
@@ -0,0 +1,484 @@
<?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
@@ -0,0 +1,170 @@
<?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
@@ -0,0 +1,275 @@
<?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
@@ -0,0 +1,473 @@
<?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>
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
# 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
+117
View File
@@ -0,0 +1,117 @@
"""
This module contains a StageScanPlanGenerator class that generates scanning plans
for microscope stages. The scans are generated in a single direction based on provided
start and end coordinates, as well as spacing between scan lines.
"""
import numpy as np
from typing import List, Tuple, Optional
class StageScanPlanGenerator:
"""
A class to generate scanning plans for microscope stages.
Attributes:
start_coords (Tuple[float, float]): Starting X and Y coordinates.
end_coords (Tuple[float, float]): Ending X and Y coordinates.
spacing (float): Spacing between scan lines in the perpendicular direction.
"""
def __init__(self, start_x: float, start_y: float,
end_x: float, end_y: float, spacing: float):
"""
Initialize the StageScanPlanGenerator with scan parameters.
Args:
start_x (float): Starting X coordinate.
start_y (float): Starting Y coordinate.
end_x (float): Ending X coordinate.
end_y (float): Ending Y coordinate.
spacing (float): Spacing between scan lines in the perpendicular direction.
"""
self.start_coords = (start_x, start_y)
self.end_coords = (end_x, end_y)
self.spacing = spacing
def _calculate_scan_direction(self) -> Tuple[float, float]:
"""
Calculate the direction vector of the scan.
Returns:
Tuple[float, float]: Normalized direction vector (dx, dy).
"""
dx = self.end_coords[0] - self.start_coords[0]
dy = self.end_coords[1] - self.start_coords[1]
length = np.sqrt(dx**2 + dy**2)
if length == 0:
raise ValueError("Start and end coordinates cannot be the same")
return dx / length, dy / length
def _calculate_perpendicular_direction(self) -> Tuple[float, float]:
"""
Calculate a perpendicular direction vector to the scan direction.
Returns:
Tuple[float, float]: Perpendicular vector (px, py).
"""
dx, dy = self._calculate_scan_direction()
# Rotate (dx, dy) by 90 degrees to get perpendicular vector
px = -dy
py = dx
return px, py
def generate_scan_plan(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
"""
Generate a scan plan with waypoints for the microscope stage.
Returns:
List[Tuple[Tuple[float, float], Tuple[float, float]]]:
A list of (start_point, end_point) tuples for each scan line.
"""
dx, dy = self._calculate_scan_direction()
px, py = self._calculate_perpendicular_direction()
# Calculate the total length in the perpendicular direction
start_x, start_y = self.start_coords
end_x, end_y = self.end_coords
min_coord_perp = min(start_x * px + start_y * py, end_x * px + end_y * py)
max_coord_perp = max(start_x * px + start_y * py, end_x * px + end_y * py)
# Generate scan lines
waypoints = []
current_pos_perp = min_coord_perp
while current_pos_perp <= max_coord_perp:
# Calculate start and end points for this scan line
perp_offset = current_pos_perp - (start_x * px + start_y * py)
line_start_x = start_x + perp_offset * dx
line_start_y = start_y + perp_offset * dy
line_end_x = line_start_x + dx * abs(self.end_coords[0] - self.start_coords[0])
line_end_y = line_start_y + dy * abs(self.end_coords[1] - self.start_coords[1])
waypoints.append(((line_start_x, line_start_y), (line_end_x, line_end_y)))
current_pos_perp += self.spacing
return waypoints
# Example usage:
if __name__ == "__main__":
# Create a scan plan generator
generator = StageScanPlanGenerator(
start_x=0.0, start_y=0.0,
end_x=10.0, end_y=10.0,
spacing=2.5
)
# Generate the scan plan
scan_plan = generator.generate_scan_plan()
# Print the scan plan
print("Scan Plan:")
for i, (start, end) in enumerate(scan_plan):
print(f"Line {i+1}: Start at {start}, End at {end}")
+797
View File
@@ -0,0 +1,797 @@
#!/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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,668 @@
# 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.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
msodev/
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
#!/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
@@ -0,0 +1,257 @@
#!/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
@@ -0,0 +1,63 @@
#!/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
@@ -0,0 +1,101 @@
#!/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
@@ -0,0 +1,292 @@
#!/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
@@ -0,0 +1,155 @@
#!/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
@@ -0,0 +1,119 @@
#!/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
@@ -0,0 +1,101 @@
#!/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
@@ -0,0 +1,60 @@
#!/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
@@ -0,0 +1,56 @@
#!/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
@@ -0,0 +1,59 @@
#!/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
@@ -0,0 +1,115 @@
#!/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
@@ -0,0 +1,162 @@
#!/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
@@ -0,0 +1,273 @@
#!/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
@@ -0,0 +1,77 @@
#!/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
@@ -0,0 +1,315 @@
# 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
@@ -0,0 +1,283 @@
# 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
@@ -0,0 +1,773 @@
"""
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
@@ -0,0 +1,256 @@
#!/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
@@ -0,0 +1 @@
pyftdi>=0.54.0
+19
View File
@@ -0,0 +1,19 @@
# scanengine-3 - Unified Requirements
# Combined dependencies from nuescan, pymso, pybbd202, and pypewpewhops
# GUI Framework (from nuescan)
PyQt6>=6.4.0
# Serial Communication (from nuescan)
pyserial>=3.5
# VISA instrument control (from nuescan - for oscilloscope/pymso)
pyvisa>=1.13.0
pyvisa-py>=0.7.0
# FTDI device support (from pypewpewhops and pybbd202)
pyftdi>=0.54.0
# Development dependencies (optional)
# pytest>=7.0.0
# pytest-qt>=4.0.0