From 23f6331ba2519ccc3f06b7135a1424d218029084 Mon Sep 17 00:00:00 2001 From: "Thomas Ales [M S E]" Date: Mon, 9 Feb 2026 14:40:34 -0600 Subject: [PATCH] when'd i last commit this pos? --- nuescan/LICENSE => LICENSE | 0 README.md | 255 +- SETUP.md | 428 ++ config.json | 28 + .../hardware}/BBD203_CONNECTION_GUIDE.md | 0 .../BBD203_Communications_Protocol.md | 0 .../hardware}/BBD203_DRIVER_README.md | 0 docs/hardware/GENESIS_LASER_README.md | 161 + .../hardware}/HELIOS_DRIVER_README.md | 0 .../laser_control_implementation_guide.md | 554 +++ .../apt_communications_protocol.pdf | Bin .../protocols}/helios_comms_protocol.pdf | Bin .../protocols/thorlabs_mls_protocol.pdf | Bin hardware/__init__.py | 6 + .../bbd202.py | 600 ++- hardware/coherent_hops_laser.py | 45 + hardware/genesis_core.py | 669 ++++ hardware/helios_laser.py | 296 ++ {pymso => hardware}/tektronix_base.py | 0 hardware/uc480_camera.py | 475 +++ nuescan/.gitignore | 207 - nuescan/README.md | 2 - nuescan/SETUP.md | 161 - nuescan/__main__.py | 30 - nuescan/dialogs/__init__.py | 3 - nuescan/dialogs/genesis_dialog.py | 71 - nuescan/dialogs/helios_dialog.py | 169 - nuescan/dialogs/oscope_dialog.py | 126 - nuescan/dialogs/scan_active_dialog.py | 163 - nuescan/dialogs/status_dialog.py | 101 - nuescan/hardware/__init__.py | 4 - nuescan/hardware/bbd203_driver.py | 928 ----- nuescan/hardware/bbd203_protocol.py | 532 --- nuescan/hardware/helios_driver.py | 627 --- nuescan/hardware/helios_protocol.py | 302 -- nuescan/hardware/microscope.py | 387 -- nuescan/hardware/stage_settings.py | 244 -- nuescan/hardware/t3r_device.py | 259 -- nuescan/hardware/thorlabs_stage.py | 651 ---- nuescan/main_window.py | 411 -- nuescan/nuescan_genesis_dialog.ui | 78 - nuescan/nuescan_helios_dialog.ui | 107 - nuescan/nuescan_mainwindow.ui | 484 --- nuescan/nuescan_oscope_dialog.ui | 170 - nuescan/nuescan_scan_active_dialog.ui | 275 -- nuescan/nuescan_status_dialog.ui | 473 --- nuescan/requirements.txt | 19 - nuescan/stage_test_app.py | 797 ---- pybbd202/LICENSE | 21 - pybbd202/README.md | 668 ---- pymso/.gitignore | 1 - pymso/test_acquire_mode.py | 65 - pymso/test_channel.py | 257 -- pymso/test_connection.py | 63 - pymso/test_fastframe.py | 101 - pymso/test_fastframe_acquisition.py | 292 -- pymso/test_fastframe_packed.py | 155 - pymso/test_fastframe_simple.py | 119 - pymso/test_horizontal.py | 101 - pymso/test_simple_curve.py | 60 - pymso/test_simple_query.py | 56 - pymso/test_single_waveform.py | 59 - pymso/test_transfer_fastframe.py | 115 - pymso/test_trigger.py | 162 - pymso/test_waveform.py | 273 -- pymso/test_waveform_simple.py | 77 - pypewpewhops/LASER_I2C_PROTOCOL.md | 315 -- pypewpewhops/README.md | 283 -- pypewpewhops/coherent_hops_laser.py | 773 ---- pypewpewhops/example_usage.py | 256 -- pypewpewhops/requirements.txt | 1 - requirements.txt | 18 +- scanengine/__init__.py | 2 + scanengine/app.py | 3432 +++++++++++++++++ scanengine/jog_stage_dialog.ui | 270 ++ scanengine/main_launcher.ui | 109 + scanengine/motion_worker.py | 426 ++ scanengine/new_scan_wizard.ui | 1113 ++++++ scanengine/options.ui | 743 ++++ scanning/__init__.py | 3 + scanning/sc3_scan_model.py | 638 +++ .../stage_scan_plan_generator.py | 0 tests/__init__.py | 1 + tests/test_bbd202_diagnostic.py | 569 +++ tests/test_bbd202_snake.py | 266 ++ tests/test_bbd202_snake_20x20.py | 300 ++ tests/test_camera_integration.py | 126 + tests/test_genesis_connection.py | 196 + tests/test_genesis_protocol.py | 237 ++ tests/test_rotated_aoi.py | 69 + tests/test_status_updates.py | 172 + tests/test_temperature_scaling.py | 71 + tools/genesis_laser_control.py | 828 ++++ tools/genesis_laser_gui.py | 1445 +++++++ 94 files changed, 14427 insertions(+), 12178 deletions(-) rename nuescan/LICENSE => LICENSE (100%) create mode 100644 SETUP.md create mode 100644 config.json rename {nuescan => docs/hardware}/BBD203_CONNECTION_GUIDE.md (100%) rename {nuescan => docs/hardware}/BBD203_Communications_Protocol.md (100%) mode change 100755 => 100644 rename {nuescan => docs/hardware}/BBD203_DRIVER_README.md (100%) create mode 100644 docs/hardware/GENESIS_LASER_README.md rename {nuescan => docs/hardware}/HELIOS_DRIVER_README.md (100%) create mode 100644 docs/hardware/laser_control_implementation_guide.md rename {nuescan => docs/protocols}/apt_communications_protocol.pdf (100%) rename {nuescan => docs/protocols}/helios_comms_protocol.pdf (100%) mode change 100755 => 100644 rename nuescan/output.pdf => docs/protocols/thorlabs_mls_protocol.pdf (100%) create mode 100644 hardware/__init__.py rename pybbd202/bbd203_controller.py => hardware/bbd202.py (83%) create mode 100644 hardware/coherent_hops_laser.py create mode 100644 hardware/genesis_core.py create mode 100644 hardware/helios_laser.py rename {pymso => hardware}/tektronix_base.py (100%) create mode 100644 hardware/uc480_camera.py delete mode 100644 nuescan/.gitignore delete mode 100644 nuescan/README.md delete mode 100644 nuescan/SETUP.md delete mode 100644 nuescan/__main__.py delete mode 100644 nuescan/dialogs/__init__.py delete mode 100644 nuescan/dialogs/genesis_dialog.py delete mode 100644 nuescan/dialogs/helios_dialog.py delete mode 100644 nuescan/dialogs/oscope_dialog.py delete mode 100644 nuescan/dialogs/scan_active_dialog.py delete mode 100644 nuescan/dialogs/status_dialog.py delete mode 100644 nuescan/hardware/__init__.py delete mode 100644 nuescan/hardware/bbd203_driver.py delete mode 100644 nuescan/hardware/bbd203_protocol.py delete mode 100644 nuescan/hardware/helios_driver.py delete mode 100644 nuescan/hardware/helios_protocol.py delete mode 100644 nuescan/hardware/microscope.py delete mode 100644 nuescan/hardware/stage_settings.py delete mode 100644 nuescan/hardware/t3r_device.py delete mode 100644 nuescan/hardware/thorlabs_stage.py delete mode 100644 nuescan/main_window.py delete mode 100644 nuescan/nuescan_genesis_dialog.ui delete mode 100644 nuescan/nuescan_helios_dialog.ui delete mode 100644 nuescan/nuescan_mainwindow.ui delete mode 100644 nuescan/nuescan_oscope_dialog.ui delete mode 100644 nuescan/nuescan_scan_active_dialog.ui delete mode 100644 nuescan/nuescan_status_dialog.ui delete mode 100644 nuescan/requirements.txt delete mode 100644 nuescan/stage_test_app.py delete mode 100644 pybbd202/LICENSE delete mode 100644 pybbd202/README.md delete mode 100644 pymso/.gitignore delete mode 100644 pymso/test_acquire_mode.py delete mode 100755 pymso/test_channel.py delete mode 100644 pymso/test_connection.py delete mode 100644 pymso/test_fastframe.py delete mode 100755 pymso/test_fastframe_acquisition.py delete mode 100644 pymso/test_fastframe_packed.py delete mode 100644 pymso/test_fastframe_simple.py delete mode 100644 pymso/test_horizontal.py delete mode 100644 pymso/test_simple_curve.py delete mode 100644 pymso/test_simple_query.py delete mode 100755 pymso/test_single_waveform.py delete mode 100644 pymso/test_transfer_fastframe.py delete mode 100644 pymso/test_trigger.py delete mode 100755 pymso/test_waveform.py delete mode 100755 pymso/test_waveform_simple.py delete mode 100644 pypewpewhops/LASER_I2C_PROTOCOL.md delete mode 100644 pypewpewhops/README.md delete mode 100644 pypewpewhops/coherent_hops_laser.py delete mode 100644 pypewpewhops/example_usage.py delete mode 100644 pypewpewhops/requirements.txt create mode 100644 scanengine/__init__.py create mode 100644 scanengine/app.py create mode 100644 scanengine/jog_stage_dialog.ui create mode 100644 scanengine/main_launcher.ui create mode 100644 scanengine/motion_worker.py create mode 100644 scanengine/new_scan_wizard.ui create mode 100644 scanengine/options.ui create mode 100644 scanning/__init__.py create mode 100644 scanning/sc3_scan_model.py rename {nuescan => scanning}/stage_scan_plan_generator.py (100%) create mode 100644 tests/__init__.py create mode 100644 tests/test_bbd202_diagnostic.py create mode 100644 tests/test_bbd202_snake.py create mode 100644 tests/test_bbd202_snake_20x20.py create mode 100644 tests/test_camera_integration.py create mode 100755 tests/test_genesis_connection.py create mode 100755 tests/test_genesis_protocol.py create mode 100644 tests/test_rotated_aoi.py create mode 100644 tests/test_status_updates.py create mode 100755 tests/test_temperature_scaling.py create mode 100755 tools/genesis_laser_control.py create mode 100755 tools/genesis_laser_gui.py diff --git a/nuescan/LICENSE b/LICENSE similarity index 100% rename from nuescan/LICENSE rename to LICENSE diff --git a/README.md b/README.md index 891aa3b..7714eb5 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,246 @@ # scanengine-3 -A unified scanning and instrumentation control platform combining multiple hardware control modules. +SRAS Scanning and Instrumentation Control Platform ## Overview -scanengine-3 merges four previously separate projects into a single, cohesive platform: +scanengine-3 is a unified platform for scanning acoustic microscopy and precision instrumentation control. It integrates multiple hardware control modules into a single cohesive PyQt6-based application. -- **nuescan**: SRAS scan planning and control software with PyQt6 GUI -- **pymso**: Tektronix oscilloscope control and data acquisition -- **pybbd202**: Thorlabs BBD203/MLS203 motor controller driver -- **pypewpewhops**: Coherent HOPS laser control via I2C +### Key Features + +- **Stage Control**: ThorLabs BBD202/BBD203 motor controller with 3-axis positioning +- **Laser Systems**: Helios and Coherent HOPS laser control +- **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support +- **Scan Planning**: Automated raster scan generation and execution +- **Real-time Monitoring**: Live status updates and progress tracking + +## Hardware Components + +### Motion Control +- **ThorLabs BBD202/BBD203 Motor Controller** + - 3-channel APT protocol driver + - Precision positioning with encoder feedback + - Programmable velocity and acceleration + - Trigger output support for synchronized data acquisition + +### Laser Systems +- **Helios Laser System** + - Frequency control (16.7-125 kHz) + - Current control (0-7000 mA) + - Multiple pulse modes + - Temperature and power monitoring + +- **Coherent HOPS Laser** + - I2C/FTDI interface + - Power and modulation control + - Temperature monitoring + +### Data Acquisition +- **Tektronix MSO/DPO Series Oscilloscopes** + - Direct socket communication (no VISA overhead) + - Fast-frame acquisition for high-speed scanning + - Multi-channel waveform capture + - Configurable triggering + +### Microscope Systems +- **Genesis Microscope** (stub implementation) +- **T3R Timing Device** (stub implementation) ## Project Structure ``` 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 +├── scanengine/ # Main application package +│ ├── __init__.py +│ ├── app.py # Main application entry point +│ ├── main_launcher.ui # Main launcher UI +│ ├── new_scan_wizard.ui # Scan wizard UI +│ └── options.ui # Options dialog UI +│ +├── hardware/ # Hardware driver package +│ ├── __init__.py +│ ├── bbd202.py # ThorLabs stage controller +│ ├── uc480_camera.py # IDS/ThorLabs camera +│ ├── tektronix_base.py # Tektronix oscilloscope +│ ├── coherent_hops_laser.py # Coherent HOPS laser +│ └── genesis_core.py # Genesis laser core logic +│ +├── scanning/ # Scan planning package +│ ├── __init__.py +│ ├── sc3_scan_model.py # Scan model +│ └── stage_scan_plan_generator.py # Scan path planning +│ +├── tools/ # Standalone executable tools +│ ├── genesis_laser_control.py # Standalone Genesis app +│ └── genesis_laser_gui.py # Alternative Genesis GUI +│ +├── tests/ # Test files +│ ├── __init__.py +│ ├── test_camera_integration.py +│ ├── test_genesis_connection.py +│ ├── test_genesis_protocol.py +│ ├── test_rotated_aoi.py +│ └── test_temperature_scaling.py +│ +├── docs/ # Documentation +│ ├── hardware/ # Hardware documentation +│ │ ├── BBD203_CONNECTION_GUIDE.md +│ │ ├── BBD203_Communications_Protocol.md +│ │ ├── BBD203_DRIVER_README.md +│ │ ├── HELIOS_DRIVER_README.md +│ │ ├── GENESIS_LASER_README.md +│ │ └── laser_control_implementation_guide.md +│ └── protocols/ # Protocol specifications +│ ├── apt_communications_protocol.pdf +│ ├── helios_comms_protocol.pdf +│ └── thorlabs_mls_protocol.pdf +│ +├── lib/ # Binary libraries (not in git) +│ ├── libueye_api64.so.3.82 +│ ├── ueye_loader.c +│ └── ueye_loader.so +│ +├── config.json # System configuration +├── requirements.txt # Python dependencies +├── README.md # This file +├── SETUP.md # Setup instructions +└── LICENSE # License file ``` -## Installation +## Quick Start -1. Create a virtual environment: - ```bash - python -m venv venv - source venv/bin/activate # On Windows: venv\Scripts\activate - ``` +### Installation -2. Install dependencies: - ```bash - pip install -r requirements.txt - ``` +```bash +# Clone or navigate to project directory +cd scanengine-3 + +# Create virtual environment (recommended) +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt +``` + +### Running the Application + +```bash +# Main GUI application +python -m scanengine.app + +# Genesis laser control tool +python tools/genesis_laser_control.py + +# Alternative Genesis laser GUI +python tools/genesis_laser_gui.py +``` ## Dependencies -- **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 +- **PyQt6** (>=6.4.0) - GUI framework +- **pyserial** (>=3.5) - Serial communication +- **pyvisa** (>=1.13.0) - VISA instrument control +- **pyvisa-py** (>=0.7.0) - Pure Python VISA backend +- **pyftdi** (>=0.54.0) - FTDI USB device support -## Usage +## Usage Examples -Refer to the README files in each subdirectory for specific usage instructions: +### Stage Control -- `nuescan/README.md` - Main application usage -- `pymso/` - Oscilloscope control examples -- `pybbd202/README.md` - Stage controller documentation -- `pypewpewhops/README.md` - Laser control documentation +```python +from hardware.bbd202 import BBD202Controller + +# BBD202/BBD203 controller example +controller = BBD202Controller() +controller.connect("/dev/ttyUSB0") # Serial port +# Use controller for stage operations +``` + +### Oscilloscope Acquisition + +```python +from hardware.tektronix_base import TektronixOscilloscopeBase + +scope = TektronixOscilloscopeBase() +scope.connect("192.168.1.100", 4000) +scope.set_acquire_mode("SAMPLE") +waveform = scope.get_curve_binary(1) # Channel 1 +``` + +### Laser Control + +```python +from hardware.coherent_hops_laser import CoherentHOPSLaser + +laser = CoherentHOPSLaser() +laser.connect() +laser.set_power_level(50.0) # 50% power +laser.enable_output(True) +``` + +### Camera Control + +```python +from hardware.uc480_camera import UC480Camera + +camera = UC480Camera(camera_id=0) +camera.initialize() +camera.start_capture() +# Camera operations +``` + +## Configuration + +### Stage Settings +Stage configuration is stored in `~/.nuescan/stage_settings.json`: +- Velocity and acceleration profiles +- Trigger configuration +- Axis limits and safety parameters + +### Serial Port Configuration +Hardware devices are accessed via: +- **BBD202/203**: USB with automatic serial number detection +- **Helios**: RS-232 serial port (9600 baud, 8N1) +- **HOPS Laser**: FTDI USB (I2C interface) +- **Oscilloscope**: Ethernet/LXI (TCP socket on port 4000) + +## Development + +### Adding New Hardware + +1. Create driver module in `hardware/` directory +2. Implement connection, control, and status methods +3. Add UI elements to main window or create new dialog +4. Connect signals in `main_window.py` + +### Testing Without Hardware + +All hardware modules include stub implementations or simulation modes. The GUI can be developed and tested without physical devices connected. + +## Documentation + +Detailed documentation available in project subdirectories: + +- [BBD202/203 Driver Guide](docs/hardware/BBD203_DRIVER_README.md) +- [BBD202/203 Connection Guide](docs/hardware/BBD203_CONNECTION_GUIDE.md) +- [BBD202/203 Communications Protocol](docs/hardware/BBD203_Communications_Protocol.md) +- [Helios Laser Guide](docs/hardware/HELIOS_DRIVER_README.md) +- [Genesis Laser Guide](docs/hardware/GENESIS_LASER_README.md) +- [Laser Control Implementation Guide](docs/hardware/laser_control_implementation_guide.md) +- [Setup Instructions](SETUP.md) ## License -Each module retains its original license. See individual subdirectories for license information. +Copyright (C) 2025 Thomas Ales +Licensed under GNU General Public License v2.0 + +See LICENSE file for full license text. + +## Support + +For issues, questions, or contributions, please refer to the project documentation or contact the development team. + +## Version + +scanengine-3 v0.1.0 - Initial unified release diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..4213ae8 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,428 @@ +# scanengine-3 Setup Guide + +Complete installation and configuration guide for the SRAS scanning platform. + +## System Requirements + +### Software Requirements +- Python 3.8 or higher (3.10+ recommended) +- pip package manager +- Git (for version control) + +### Operating Systems +- Linux (primary development platform) +- Windows 10/11 +- macOS (limited testing) + +### Hardware Requirements +Optional - application runs in simulation mode without hardware: +- USB ports for ThorLabs BBD202/203 and FTDI devices +- Serial (RS-232) port or USB-to-serial adapter for Helios laser +- Network connection for Tektronix oscilloscope (Ethernet/LXI) + +## Installation + +### 1. Create Virtual Environment + +Using a virtual environment is strongly recommended to isolate dependencies. + +```bash +# Navigate to project directory +cd scanengine-3 + +# Create virtual environment +python -m venv venv + +# Activate virtual environment +# On Linux/macOS: +source venv/bin/activate + +# On Windows: +venv\Scripts\activate +``` + +### 2. Install Python Dependencies + +```bash +# Install all required packages +pip install -r requirements.txt + +# Or install individually: +pip install PyQt6>=6.4.0 +pip install pyserial>=3.5 +pip install pyvisa>=1.13.0 +pip install pyvisa-py>=0.7.0 +pip install pyftdi>=0.54.0 +``` + +### 3. Verify Installation + +```bash +# Test Python imports +python -c "import PyQt6; import serial; import pyvisa; print('Dependencies OK')" + +# List connected serial devices (optional) +python -c "import serial.tools.list_ports; print(list(serial.tools.list_ports.comports()))" +``` + +## Hardware Setup + +### ThorLabs BBD202/BBD203 Motor Controller + +**Connection:** +1. Connect BBD202/203 controller to PC via USB +2. Power on the controller +3. Note the serial number printed on the device (8 digits) + +**Linux-specific:** +```bash +# Add user to dialout group for serial access +sudo usermod -a -G dialout $USER +# Log out and back in for changes to take effect + +# Verify USB connection +lsusb | grep -i thorlabs +``` + +**Windows-specific:** +- Install ThorLabs APT software to get USB drivers +- Verify device appears in Device Manager under "Ports (COM & LPT)" + +**First-time setup:** +```bash +# Run the stage test application +python stage_test_app.py + +# Enter your BBD203 serial number +# Click "Connect" to test the connection +# Use "Home All Axes" to verify operation +``` + +### Helios Laser System + +**Connection:** +1. Connect Helios laser to RS-232 serial port +2. Configure serial settings: 9600 baud, 8 data bits, no parity, 1 stop bit (8N1) +3. Note the COM port name (e.g., COM3 on Windows, /dev/ttyUSB0 on Linux) + +**Linux-specific:** +```bash +# Identify serial port +ls -l /dev/ttyUSB* /dev/ttyS* + +# Test connection (optional, if helios driver available) +# python -c "from hardware.helios_driver import HeliosDriver; d = HeliosDriver('/dev/ttyUSB0'); print('Connected:', d.connect())" +``` + +### Coherent HOPS Laser + +**Connection:** +1. Connect HOPS laser to PC via FTDI USB cable +2. Laser communicates over I2C protocol through FTDI interface + +**Driver installation:** +```bash +# Linux: Install libftdi (if not already present) +sudo apt install libftdi1-dev # Debian/Ubuntu +sudo dnf install libftdi-devel # Fedora + +# Verify FTDI device +python -c "from pyftdi.ftdi import Ftdi; Ftdi.show_devices()" +``` + +**First-time setup:** +```bash +# Test laser connection +python -c "from hardware.coherent_hops_laser import CoherentHOPSLaser; laser = CoherentHOPSLaser(); print('Connected:', laser.connect())" +``` + +### Tektronix Oscilloscope + +**Connection:** +1. Connect oscilloscope to network via Ethernet +2. Configure oscilloscope IP address (static recommended) +3. Enable LXI server on oscilloscope (Utility → I/O → Network → LXI) + +**Network configuration:** +```bash +# Verify connectivity +ping + +# Test connection +python -c "from hardware.tektronix_base import TektronixOscilloscopeBase; scope = TektronixOscilloscopeBase(); scope.connect('', 4000); print('Connected')" +``` + +## Running the Application + +### Main Application + +```bash +# Run main application +python -m scanengine.app +``` + +**On first launch:** +1. Main window opens with scan launcher interface +2. Configure system settings before starting scans +3. Use "Options" to configure hardware connections + +### Genesis Laser Control Tools + +For standalone Genesis laser control: + +```bash +# Full-featured Genesis laser control app +python tools/genesis_laser_control.py + +# Alternative Genesis GUI +python tools/genesis_laser_gui.py +``` + +Features: +- Current and power control +- Shutter and keyswitch control +- Real-time monitoring +- Interlock status +- Temperature readings +- Raw I2C packet interface + +## Configuration + +### Stage Settings + +Stage configuration is automatically saved to: +``` +~/.nuescan/stage_settings.json (Linux/macOS) +%USERPROFILE%\.nuescan\stage_settings.json (Windows) +``` + +Settings include: +- Velocity profiles per axis +- Acceleration profiles +- Trigger output configuration +- Last used serial number + +**Manual editing:** +```json +{ + "x_axis": { + "velocity": 2.0, + "acceleration": 5.0, + "trigger": { + "mode": 1, + "polarity": 0, + "start_pos_fwd": 0.0, + "interval_fwd": 1.0 + } + } +} +``` + +### Application Settings + +Main window settings (geometry, last used values) are stored in Qt settings: +``` +~/.config/SRAS/nueScan.conf (Linux) +%APPDATA%\SRAS\nueScan.ini (Windows) +``` + +## Project Structure + +``` +scanengine-3/ +│ +├── scanengine/ # Main application package +│ ├── __init__.py +│ ├── app.py # Main application entry point +│ ├── main_launcher.ui # Main launcher UI +│ ├── new_scan_wizard.ui # Scan wizard UI +│ └── options.ui # Options dialog UI +│ +├── hardware/ # Hardware driver package +│ ├── __init__.py +│ ├── bbd202.py # ThorLabs stage controller +│ ├── uc480_camera.py # IDS/ThorLabs camera +│ ├── tektronix_base.py # Tektronix oscilloscope +│ ├── coherent_hops_laser.py # Coherent HOPS laser +│ └── genesis_core.py # Genesis laser core logic +│ +├── scanning/ # Scan planning package +│ ├── __init__.py +│ ├── sc3_scan_model.py # Scan model +│ └── stage_scan_plan_generator.py # Scan path planning +│ +├── tools/ # Standalone executable tools +│ ├── genesis_laser_control.py # Standalone Genesis app +│ └── genesis_laser_gui.py # Alternative Genesis GUI +│ +├── tests/ # Test files +│ ├── __init__.py +│ ├── test_camera_integration.py +│ ├── test_genesis_connection.py +│ ├── test_genesis_protocol.py +│ ├── test_rotated_aoi.py +│ └── test_temperature_scaling.py +│ +├── docs/ # Documentation +│ ├── hardware/ # Hardware documentation +│ │ ├── BBD203_CONNECTION_GUIDE.md +│ │ ├── BBD203_Communications_Protocol.md +│ │ ├── BBD203_DRIVER_README.md +│ │ ├── HELIOS_DRIVER_README.md +│ │ ├── GENESIS_LASER_README.md +│ │ └── laser_control_implementation_guide.md +│ └── protocols/ # Protocol specifications +│ ├── apt_communications_protocol.pdf +│ ├── helios_comms_protocol.pdf +│ └── thorlabs_mls_protocol.pdf +│ +├── lib/ # Binary libraries (not in git) +│ ├── libueye_api64.so.3.82 +│ ├── ueye_loader.c +│ └── ueye_loader.so +│ +├── config.json # System configuration +├── requirements.txt # Python dependencies +├── README.md # Project overview +├── SETUP.md # This file +└── LICENSE # License file +``` + +## Troubleshooting + +### Common Issues + +**1. Import Errors** +``` +ModuleNotFoundError: No module named 'PyQt6' +``` +**Solution:** Ensure virtual environment is activated and dependencies are installed +```bash +source venv/bin/activate # or venv\Scripts\activate on Windows +pip install -r requirements.txt +``` + +**2. Serial Port Access Denied (Linux)** +``` +PermissionError: [Errno 13] Permission denied: '/dev/ttyUSB0' +``` +**Solution:** Add user to dialout group +```bash +sudo usermod -a -G dialout $USER +# Log out and back in +``` + +**3. BBD202/203 Not Detected** +- Verify USB cable is connected and device is powered on +- Check serial number is correct (8 digits, case-sensitive) +- On Windows, verify ThorLabs APT drivers are installed +- Try different USB port + +**4. Oscilloscope Connection Failed** +- Verify network connectivity with `ping` +- Ensure oscilloscope LXI server is enabled +- Check firewall settings (port 4000 must be open) +- Verify IP address is correct + +**5. PyQt6 UI Loading Errors** +``` +uic.loadUi() failed to load .ui file +``` +**Solution:** Ensure .ui files are in same directory as main script, or check file paths + +### Debug Mode + +Enable verbose logging: + +```python +# Add to __main__.py before creating QApplication +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +All hardware modules print status messages: +- `DEBUG:` - Detailed operation information +- `INFO:` - Normal operations +- `WARNING:` - Potential issues +- `ERROR:` - Operation failures + +## Development Workflow + +### UI Modifications + +1. Edit `.ui` files using Qt Designer: +```bash +designer nuescan_mainwindow.ui +``` + +2. UI files are loaded dynamically at runtime - no compilation needed + +3. Access UI elements in code: +```python +self.ui.buttonName.clicked.connect(self.handler_method) +``` + +### Adding New Hardware + +1. Create driver module in `hardware/` directory +2. Implement required methods: + - `connect()` / `disconnect()` + - `is_connected()` + - `get_status()` +3. Add to main window or create dedicated dialog +4. Update UI to include new hardware section + +### Testing Without Hardware + +All hardware drivers support operation without physical devices: +- Stage: Simulated position and status +- Lasers: Accept commands without hardware validation +- Oscilloscope: Can be tested with scope simulator + +Run application normally - missing hardware will log warnings but won't prevent startup. + +## Performance Optimization + +### Fast Data Acquisition + +For high-speed scanning with oscilloscope: +1. Use wired Ethernet (not Wi-Fi) +2. Set oscilloscope to 1 Gb Ethernet if available +3. Enable binary data transfer format +4. Use fast-frame mode for multi-point scans + +### Stage Movement Optimization + +For optimal scan performance: +1. Home all axes before starting scan +2. Set appropriate velocity limits (2-5 mm/s typical) +3. Configure trigger output for synchronized acquisition +4. Use continuous motion scans when possible + +## Additional Resources + +- [ThorLabs APT Protocol Manual](docs/protocols/apt_communications_protocol.pdf) +- [Helios Communication Protocol](docs/protocols/helios_comms_protocol.pdf) +- [ThorLabs MLS Protocol](docs/protocols/thorlabs_mls_protocol.pdf) +- [BBD202/203 Driver Documentation](docs/hardware/BBD203_DRIVER_README.md) +- [BBD202/203 Connection Guide](docs/hardware/BBD203_CONNECTION_GUIDE.md) +- [Helios Driver Documentation](docs/hardware/HELIOS_DRIVER_README.md) +- [Genesis Laser Documentation](docs/hardware/GENESIS_LASER_README.md) +- [Laser Control Implementation Guide](docs/hardware/laser_control_implementation_guide.md) + +## License + +Copyright (C) 2025 Thomas Ales +Licensed under GNU General Public License v2.0 + +## Support + +For issues or questions: +1. Check troubleshooting section above +2. Review hardware-specific documentation +3. Examine console output for error messages +4. Contact development team + +--- + +scanengine-3 v0.1.0 diff --git a/config.json b/config.json new file mode 100644 index 0000000..83f928a --- /dev/null +++ b/config.json @@ -0,0 +1,28 @@ +{ + "detection_laser": { + "scan_power_mw": "125" + }, + "generation_laser": { + "com_port": "/dev/ttyUSB0", + "frequency_hz": "20000", + "pump_diode_current_ma": "750", + "focusing_frequency_hz": "20000", + "focusing_pump_current_ma": "300" + }, + "scanning_stage": { + "scan_velocity_mm_s": "200", + "scan_acceleration_mm_s2": "500", + "x_trigger_mode": 5, + "y_trigger_mode": 0, + "optical_axis_x_mm": "55", + "optical_axis_y_mm": "37.5" + }, + "t3r": { + "com_port": "/dev/ttyUSB0" + }, + "oscilloscope": { + "socket_address": "192.168.0.1", + "scratch_directory": "/opt/", + "save_location": "pc" + } +} diff --git a/nuescan/BBD203_CONNECTION_GUIDE.md b/docs/hardware/BBD203_CONNECTION_GUIDE.md similarity index 100% rename from nuescan/BBD203_CONNECTION_GUIDE.md rename to docs/hardware/BBD203_CONNECTION_GUIDE.md diff --git a/nuescan/BBD203_Communications_Protocol.md b/docs/hardware/BBD203_Communications_Protocol.md old mode 100755 new mode 100644 similarity index 100% rename from nuescan/BBD203_Communications_Protocol.md rename to docs/hardware/BBD203_Communications_Protocol.md diff --git a/nuescan/BBD203_DRIVER_README.md b/docs/hardware/BBD203_DRIVER_README.md similarity index 100% rename from nuescan/BBD203_DRIVER_README.md rename to docs/hardware/BBD203_DRIVER_README.md diff --git a/docs/hardware/GENESIS_LASER_README.md b/docs/hardware/GENESIS_LASER_README.md new file mode 100644 index 0000000..1efb2b2 --- /dev/null +++ b/docs/hardware/GENESIS_LASER_README.md @@ -0,0 +1,161 @@ +# Genesis SLM MX 532 Laser Control Application + +## Overview + +This application provides comprehensive control of the Genesis SLM MX 532 laser over serial port using the NXP I2C-over-serial protocol. It features a professional PyQt6 GUI with multiple tabs for basic controls, monitoring, advanced operations, and configuration. + +## Features + +### Tab 1: Basic Controls +- **Current Control**: Slider and numeric input for laser current (0-1023) with percentage display +- **Power Control**: Slider for power command setting +- **Digital Controls**: Toggle buttons for: + - Shutter (Open/Closed) + - Keyswitch (On/Off) + - Remote Enable (On/Off) + - Analog Input Enable (On/Off) + - Current Mode (On/Off) +- **Emergency Stop**: Red button that immediately closes shutter, sets current to 0, and disables keyswitch + +### Tab 2: Monitoring +- Real-time sensor readings with auto-refresh capability (configurable interval) +- Displays: + - Actual current reading from ADC + - Interlock status (visual color-coded indicator) + - LDD (Laser Diode Driver) enable status + - Power supply glue input/output status + - Head DIO status +- Laser information display (model and wavelength) + +### Tab 3: Advanced +- **Raw I2C Packet Sender**: Send custom I2C commands with hex input +- **Packet Capture Log**: Real-time log of all transmitted and received packets with timestamps +- Useful for debugging and development + +### Tab 4: Configuration +- Serial port selection with auto-detection +- Baud rate configuration (default: 9600) +- Connect/Disconnect control +- DTR/RTS control line settings +- About section with protocol information + +## Safety Features + +1. **All controls disabled until connected** - Prevents accidental commands +2. **Shutter confirmation dialog** - Warns if opening shutter with current > 0 +3. **Emergency stop** - Always enabled, immediately enters safe state +4. **Auto-safe state on disconnect** - Laser enters safe state when disconnecting +5. **Settings persistence** - Window geometry and last settings are saved + +## Protocol Details + +The application uses the NXP I2C-over-serial protocol with this packet format: + +**Write packet:** +``` +[0x53] [I2C_ADDR] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50] +``` + +**Read packet:** +``` +[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50] +``` + +### I2C Devices + +- **X9119** (0x52/0x53) - Digital potentiometer for current control +- **PCA9555** (various addresses) - I/O expanders for digital control +- **ADS7828** (0x90/0x91) - ADC for sensor readings +- **AD5254** (0x58) - Digital potentiometer for limits +- **M24C64** (0xa4/0xa5) - EEPROM for configuration storage + +## Installation + +1. Ensure dependencies are installed: + ```bash + pip install -r requirements.txt + ``` + +2. Connect the laser to your computer via serial port (typically /dev/ttyUSB0 on Linux) + +3. Run the application: + ```bash + python genesis_laser_control.py + ``` + +## Usage + +1. **Launch the application** + ```bash + python genesis_laser_control.py + ``` + +2. **Connect to the laser:** + - Go to the "Configuration" tab + - Select the correct serial port from the dropdown + - Verify baud rate is set to 9600 (default) + - Click "Connect" + +3. **Basic operation:** + - Enable remote control using the "Remote: DISABLED" button + - Enable keyswitch if required + - Adjust current using the slider + - Open shutter when ready (confirmation dialog will appear if current > 0) + +4. **Monitoring:** + - Go to the "Monitoring" tab + - Enable "Auto-refresh" to continuously update readings + - Adjust refresh interval as needed (default: 500ms) + +5. **Emergency stop:** + - Click the red "EMERGENCY STOP" button on any tab + - This immediately closes shutter, sets current to 0, and disables keyswitch + +6. **Disconnecting:** + - Click "Disconnect" in the Configuration tab + - Laser will automatically enter safe state before disconnecting + +## Troubleshooting + +### Connection Issues +- Verify serial port permissions: `sudo usermod -a -G dialout $USER` (logout and login) +- Check cable connection and power +- Verify correct port in Configuration tab +- Try refreshing ports list + +### Communication Errors +- Check packet log in Advanced tab for detailed TX/RX +- Verify baud rate is 9600 +- Ensure no other programs are using the serial port +- Try power cycling the laser + +### Interlock Faults +- Check physical interlock connections +- Verify interlock status in Monitoring tab +- Ensure all safety covers are in place + +## Code Structure + +The application is organized into several classes: + +- **SerialComm**: Low-level serial port communication +- **I2CProtocol**: NXP packet construction and parsing +- **I2CDevices**: I2C device-specific functions (PCA9555, X9119, ADS7828) +- **LaserControl**: High-level laser control operations +- **MainWindow**: PyQt6 GUI and user interaction + +## Development + +To modify the application: + +1. **Adding new I2C devices**: Extend the `I2CDevices` class +2. **Adding new controls**: Add methods to `LaserControl` and corresponding UI elements +3. **Custom commands**: Use the Raw I2C Packet Sender in the Advanced tab for testing + +## License + +See LICENSE file for details. + +## Support + +For issues or questions, please refer to the main project documentation. diff --git a/nuescan/HELIOS_DRIVER_README.md b/docs/hardware/HELIOS_DRIVER_README.md similarity index 100% rename from nuescan/HELIOS_DRIVER_README.md rename to docs/hardware/HELIOS_DRIVER_README.md diff --git a/docs/hardware/laser_control_implementation_guide.md b/docs/hardware/laser_control_implementation_guide.md new file mode 100644 index 0000000..3fed6e9 --- /dev/null +++ b/docs/hardware/laser_control_implementation_guide.md @@ -0,0 +1,554 @@ +# Genesis SLM MX 532 Laser Control - PyQt6 Implementation Guide + +## Protocol Overview +The laser uses NXP I2C-over-serial protocol on /dev/ttyUSB0 at 9600 8N1. + +### Packet Format +**Write:** +``` +[0x53] [I2C_ADDR_WRITE] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50] +``` + +**Read:** +``` +[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50] +``` + +## I2C Device Map + +| Device | Address | Write | Read | Purpose | +|--------|---------|-------|------|---------| +| X9119 (current) | 0x152 | 0x52 | 0x53 | Laser current control | +| X9119 (photo) | 0x152 | 0x52 | 0x53 | Power feedback control | +| PCA9555 (PS DIO) | 0x140 | 0x40 | 0x41 | Power supply digital I/O | +| PCA9555 (Head DIO) | 0x144 | 0x44 | 0x45 | Head digital I/O | +| PCA9555 (PS Glue In) | 0x148 | 0x48 | 0x49 | Power supply glue logic input | +| PCA9555 (PS Glue Out) | 0x14a | 0x4a | 0x4b | Power supply glue logic output | +| AD5254 (current limit) | 0x158 | 0x58 | 0x59 | Current limit potentiometer | +| AD5254 (photo limit) | 0x158 | 0x58 | 0x59 | Photo limit potentiometer | +| ADS7828 (ADC) | 0x190 | 0x90 | 0x91 | Analog sensor readings | +| M24C64 (EEPROM) | 0x2a4 | 0xa4 | 0xa5 | Configuration storage | +| STM32 (MCU) | 0x2ae | 0xae | 0xaf | Microcontroller | +| DS1682 (hour meter) | 0x1d6 | 0xd6 | 0xd7 | Hour meter | + +## Control Commands (from SendI2cCommand) + +### SET Commands with Vtable Offsets + +| Command | Offset | Device | Details | +|---------|--------|--------|---------| +| PCMD= | 0xf8 | X9119 @ 0x152 | Power command, cmd 0xa0, 0-1023 | +| PMEM= | 0x100 | X9119 @ 0x152 | Power memory (writes to NV) | +| CCMD= | 0xe8 | X9119 @ 0x152 | Current command, cmd 0xa0, 0-1023 | +| SHCMD= | 0x54 | PCA9555 @ 0x14a | Shutter, port 0, bit 0x01 | +| KSWCMD= | 0x60 | PCA9555 @ 0x14a | Keyswitch, port 0, bit 0x20 | +| CMODECMD= | 0x84 | PCA9555 @ 0x14a | Current mode, port 0, bit 0x04 | +| ANACMD= | 0x70 | PCA9555 @ 0x14a | Analog enable, port 0, bit 0x10 | +| REM= | 0x7c | PCA9555 @ 0x14a | Remote enable, port 0, bit 0x08 | + +### QUERY Commands with Vtable Offsets + +| Command | Offset | Returns | +|---------|--------|---------| +| ?TMAINCMD | 0xdc | Main temperature command | +| ?MAIND | 0xe0 | Main diode current | +| ?PSGLUEIN | 0x44 | PS glue input (PCA9555 @ 0x148) | +| ?PSGLUEOUT | 0x48 | PS glue output (PCA9555 @ 0x14a) | +| ?HEADDIO | 0x4c | Head DIO (PCA9555 @ 0x144) | +| ?WAVELENGTH | 0x14 | Wavelength reading | +| ?LASERMODEL | 0x10 | Model string | +| ?POWERUNITS | 0x110 | Power units string | + +## Device-Specific Commands + +### X9119 Digital Potentiometer +- **Write wiper:** Command 0xa0, 2 bytes (10-bit value 0-1023) +- **Valid range:** 0-1023 +- **Error message:** "Valid code range is 0-1023" + +### PCA9555 I/O Expander Registers +| Register | Address | Purpose | +|----------|---------|---------| +| Input Port 0 | 0x00 | Read input state | +| Input Port 1 | 0x01 | Read input state | +| Output Port 0 | 0x02 | Write output state | +| Output Port 1 | 0x03 | Write output state | +| Polarity Inv 0 | 0x04 | Invert polarity | +| Polarity Inv 1 | 0x05 | Invert polarity | +| Config Port 0 | 0x06 | Direction (0=out, 1=in) | +| Config Port 1 | 0x07 | Direction (0=out, 1=in) | + +### PCA9555 @ 0x14a (Power Supply Glue Out) Bit Assignments +| Bit | Mask | Function | +|-----|------|----------| +| 0 | 0x01 | Shutter command | +| 2 | 0x04 | Current mode command | +| 3 | 0x08 | Remote enable command | +| 4 | 0x10 | Analog input enable | +| 5 | 0x20 | Keyswitch command | + +### ADS7828 ADC Commands +- **Current actual:** Channel 0x84 +- **Photo actual (alt):** Channel 0xe4 +- **Main temp actual:** Channel 0x94 + +## Read Strategies (Critical!) + +The DLL uses three different read strategies: + +### 1. ReadOne - Single Read +```python +def i2c_read_one(addr, cmd, data_len): + """Single read, no filtering. Use for digital I/O.""" + return nxp_read(addr, cmd, 1 if cmd <= 0xFF else 2, data_len) +``` +**Use for:** Digital I/O states (PCA9555 reads) + +### 2. ReadMatchTwo - Match Until Consistent +```python +def i2c_read_match_two(addr, cmd, data_len, max_attempts=5): + """Read until 2 values match (up to 5 attempts). Most reliable.""" + readings = [] + for attempt in range(max_attempts): + value = nxp_read(addr, cmd, 1 if cmd <= 0xFF else 2, data_len) + readings.append(value) + if readings.count(value) >= 2: + return value + time.sleep(0.01) + return readings[0] +``` +**Use for:** EEPROM reads, configuration values + +### 3. ReadDiscardHighLow - Median Filter +```python +def i2c_read_discard_high_low(addr, cmd, data_len): + """Read 3 times, return median. Filters noise.""" + readings = [] + for _ in range(3): + value = nxp_read(addr, cmd, 1 if cmd <= 0xFF else 2, data_len) + readings.append(value) + time.sleep(0.01) + return sorted(readings)[1] # Median +``` +**Use for:** ADC readings (current, temperature, voltage) + +## Implementation Requirements + +### Core Protocol Functions + +```python +def nxp_write(i2c_addr_write, cmd, data, data_len): + """ + Build NXP I2C write packet. + + Args: + i2c_addr_write: I2C device address (write bit cleared) + cmd: Command byte(s) - int for 1 byte, int for 2 bytes + data: Data value to write + data_len: 1, 2, or 4 bytes + + Packet: [0x53][addr][len][cmd...][data...][0x50] + """ + pass + +def nxp_read(i2c_addr_write, cmd, cmd_len, data_len): + """ + Build NXP I2C read packet. + + Args: + i2c_addr_write: I2C device address (write bit cleared) + cmd: Command byte(s) + cmd_len: 1 or 2 bytes + data_len: Number of bytes to read + + Packet: [0x53][addr][cmd_len][cmd...][0x53][addr|0x01][data_len][0x50] + Returns: Bytes read from device + """ + pass + +def pca9555_read_port(addr, port): + """Read PCA9555 port register (0x00-0x07).""" + return i2c_read_one(addr, port, 1) + +def pca9555_write_port(addr, port, value): + """Write PCA9555 port register.""" + nxp_write(addr, port, value, 1) + +def pca9555_set_bit(addr, port, bitmask, state): + """ + Set or clear specific bit on PCA9555. + Reads current value, modifies bit, writes back. + """ + current = pca9555_read_port(addr, 0x02 + port) # Output port + if state: + new_value = current | bitmask + else: + new_value = current & ~bitmask + pca9555_write_port(addr, 0x02 + port, new_value) + +def x9119_write_wiper(addr, value): + """Set X9119 wiper position (0-1023).""" + if value > 1023: + raise ValueError("X9119 value must be 0-1023") + nxp_write(addr, 0xa0, value, 2) + +def ads7828_read(addr, channel): + """Read ADS7828 ADC channel.""" + return i2c_read_discard_high_low(addr, channel, 2) +``` + +### High-Level Control Functions + +```python +def set_current(value): + """CCMD= - Set laser current (0-1023).""" + x9119_write_wiper(0x52, value) + +def set_power_cmd(value): + """PCMD= - Set power command (0-1023).""" + x9119_write_wiper(0x52, value) # Same device, different context + +def set_shutter(state): + """SHCMD= - Control shutter (True=open, False=closed).""" + pca9555_set_bit(0x4a, 0, 0x01, state) + +def set_keyswitch(state): + """KSWCMD= - Control keyswitch (True=on, False=off).""" + pca9555_set_bit(0x4a, 0, 0x20, state) + +def set_remote_enable(state): + """REM= - Enable remote control (True=enabled).""" + pca9555_set_bit(0x4a, 0, 0x08, state) + +def set_analog_enable(state): + """ANACMD= - Enable analog input (True=enabled).""" + pca9555_set_bit(0x4a, 0, 0x10, state) + +def set_current_mode(state): + """CMODECMD= - Set current mode (True=enabled).""" + pca9555_set_bit(0x4a, 0, 0x04, state) + +def get_current_actual(): + """Read actual current from ADC.""" + raw = ads7828_read(0x90, 0x84) + # TODO: Scale based on AmpsFullscale + return raw * 0.000244140625 # Placeholder scaling + +def get_main_temp(): + """Read main crystal temperature.""" + return ads7828_read(0x90, 0x94) + +def get_interlock_status(): + """Check interlock status.""" + value = pca9555_read_port(0x48, 0x00) # Input port 0 + return bool(value & 0x01) + +def get_ldd_enable(): + """Check if laser diode driver is enabled.""" + value = pca9555_read_port(0x40, 0x00) + return not bool(value & 0x01) # Inverted logic +``` + +## GUI Structure + +### Tab 1: Basic Controls +- **Current Control** + - Slider: 0-1023 + - Spinbox: Numeric input + - Label: Percentage (0-100%) + - Button: Set to 0 (quick disable) + +- **Power Command** + - Slider: 0-1023 + - Spinbox: Numeric input + +- **Digital Controls (Toggle Buttons)** + - Shutter (Open/Closed) - Red when open + - Keyswitch (On/Off) + - Remote Enable (On/Off) + - Analog Input (On/Off) + - Current Mode (On/Off) + +- **Emergency Stop** + - Large red button + - Always enabled (even when disconnected) + - Actions: Close shutter, current=0, keyswitch=off + +- **Status Display** + - Actual current reading (live) + - Connection status indicator + +### Tab 2: Monitoring +- **Real-Time Readings** (auto-refresh 500ms) + - Current Actual: [value] A + - Main Temperature: [value] °C + - SHG Temperature: [value] °C + - Interlock Status: [OK/FAULT] (colored indicator) + - LDD Enable: [ON/OFF] + +- **System Info** (read once on connect) + - Laser Model: [from EEPROM] + - Wavelength: [value] nm + - Serial Number: [from EEPROM] + +- **Controls** + - Refresh Rate slider (100ms - 2000ms) + - Enable/Disable auto-refresh checkbox + - Manual refresh button + +### Tab 3: Advanced +- **Raw I2C Interface** + - Device Address (hex): [input] + - Command (hex): [input] + - Data (hex): [input] + - Data Length: [dropdown: 1/2/4 bytes] + - Buttons: [Write] [Read] + - Response display (hex) + +- **Packet Monitor** + - Scrolling log of all TX/RX + - Format: `[HH:MM:SS.mmm] TX: 53 52 02 a0 01 00 50` + - Clear button + - Export to file button + +- **EEPROM Tools** + - Warning label (red): "Caution: Can damage laser!" + - Read Location (hex): [input] + - Read Length: [input] + - [Read] button + - Data display + - Write protection checkbox + +### Tab 4: Configuration +- **Serial Port** + - Dropdown: Auto-detect available ports + - Baud Rate: [dropdown: 9600 default] + - Data Bits: 8 (fixed) + - Parity: None (fixed) + - Stop Bits: 1 (fixed) + +- **Connection** + - [Connect]/[Disconnect] button (toggle) + - DTR checkbox + - RTS checkbox + - Status: [Connected/Disconnected] + +- **Debug Options** + - Show raw sensor values (before filtering) checkbox + - Packet timeout (ms): [input] + - Retry count: [input] + +## Safety Features + +### Pre-Flight Checks +Before opening shutter, verify: +1. Remote enable is ON +2. Keyswitch is ON +3. Interlock is OK +4. Current is set to safe value +5. Show confirmation dialog + +### Automatic Safety +- All controls disabled until serial connected +- Emergency stop always enabled +- Auto-disable on serial errors +- Auto-close shutter on disconnect +- Set current to 0 on disconnect +- Watchdog timer - if no commands for 5 seconds, safe state + +### Error Handling +- Catch all serial exceptions +- Display errors in status bar +- Log errors with timestamps +- Retry logic for critical operations +- Timeout handling (default 1 second) + +## Code Organization + +### Class Structure +``` +LaserControlApp (QMainWindow) +├── SerialComm (QObject) +│ ├── Methods: connect(), disconnect(), write_packet(), read_packet() +│ └── Signals: connected, disconnected, error, data_received +│ +├── I2CProtocol (QObject) +│ ├── Low-level: nxp_write(), nxp_read() +│ ├── Device-specific: pca9555_*, x9119_*, ads7828_* +│ └── Read strategies: read_one(), read_match_two(), read_discard_high_low() +│ +├── LaserControl (QObject) +│ ├── High-level: set_current(), set_shutter(), etc. +│ ├── Monitoring: get_current_actual(), get_temps(), etc. +│ └── Safety: emergency_stop(), safe_state(), pre_flight_check() +│ +└── UI Tabs + ├── BasicControlTab (QWidget) + ├── MonitoringTab (QWidget) + ├── AdvancedTab (QWidget) + └── ConfigTab (QWidget) +``` + +### Constants (at top of file) +```python +# I2C Addresses (7-bit, shifted) +ADDR_X9119_CURRENT = 0x52 +ADDR_PCA9555_PS_DIO = 0x40 +ADDR_PCA9555_HEAD_DIO = 0x44 +ADDR_PCA9555_PS_GLUE_IN = 0x48 +ADDR_PCA9555_PS_GLUE_OUT = 0x4a +ADDR_AD5254 = 0x58 +ADDR_ADS7828 = 0x90 +ADDR_EEPROM = 0xa4 +ADDR_STM32 = 0xae + +# PCA9555 Bit Masks (0x14a) +BIT_SHUTTER = 0x01 +BIT_CURRENT_MODE = 0x04 +BIT_REMOTE_ENABLE = 0x08 +BIT_ANALOG_ENABLE = 0x10 +BIT_KEYSWITCH = 0x20 + +# X9119 Commands +CMD_X9119_WRITE_WIPER = 0xa0 + +# ADS7828 Channels +CHAN_CURRENT_ACTUAL = 0x84 +CHAN_PHOTO_ACTUAL = 0xe4 +CHAN_MAIN_TEMP = 0x94 + +# Serial Protocol +NXP_START_BYTE = 0x53 +NXP_STOP_BYTE = 0x50 +``` + +## Styling + +Use QSS for professional appearance: + +```python +STYLESHEET = """ +QMainWindow { + background-color: #2b2b2b; +} + +QPushButton { + background-color: #3c3c3c; + color: white; + border: 1px solid #555; + padding: 5px; + border-radius: 3px; +} + +QPushButton:hover { + background-color: #4c4c4c; +} + +QPushButton:pressed { + background-color: #2c2c2c; +} + +QPushButton#emergency { + background-color: #cc0000; + font-weight: bold; + font-size: 14pt; +} + +QPushButton#emergency:hover { + background-color: #ff0000; +} + +QLabel#status_ok { + color: #00ff00; + font-weight: bold; +} + +QLabel#status_warning { + color: #ffaa00; + font-weight: bold; +} + +QLabel#status_error { + color: #ff0000; + font-weight: bold; +} + +QSlider::groove:horizontal { + background: #3c3c3c; + height: 8px; +} + +QSlider::handle:horizontal { + background: #5c5c5c; + width: 16px; + margin: -4px 0; + border-radius: 8px; +} +""" +``` + +## Testing Checklist + +Before Monday demo: +- [ ] Serial connection/disconnection works +- [ ] Current slider sets X9119 correctly +- [ ] Shutter opens/closes (verify with read-back) +- [ ] Emergency stop works from any state +- [ ] Sensor readings update (current, temp) +- [ ] Error handling doesn't crash app +- [ ] All safety interlocks functional +- [ ] Confirm dialog before shutter open +- [ ] Auto-disable on disconnect +- [ ] Packet monitor shows correct data + +## README Section for Top of File + +```python +""" +Genesis SLM MX 532 Laser Control Application +============================================= + +This application controls a Genesis SLM MX 532 laser via I2C-over-serial protocol. + +PROTOCOL OVERVIEW: +- Serial: /dev/ttyUSB0 @ 9600 8N1 +- Protocol: NXP I2C tunneled over serial +- Packet format: [0x53][addr][len][cmd][data][0x50] + +SAFETY WARNINGS: +- Always close shutter before adjusting current +- Verify interlock status before opening shutter +- Use emergency stop if anything looks wrong +- Never bypass safety interlocks + +USAGE: +1. Connect serial port in Configuration tab +2. Enable Remote and Keyswitch +3. Set current to desired level +4. Open shutter (with confirmation) +5. Monitor temperature and current +6. Close shutter when done + +For more information, see protocol documentation. + +Author: [Your Name] +Date: [Date] +License: [License] +""" +``` + +## Notes for Implementation + +- All I2C addresses are 7-bit and need write bit cleared (& 0xFE) for writes +- Read addresses are write address | 0x01 +- Multi-byte data is big-endian +- Always validate inputs before sending to hardware +- Use type hints throughout +- Add docstrings to all functions +- Log all I2C transactions for debugging +- Include unit tests for packet construction +- Make sure emergency stop can interrupt any operation + +--- + +**CRITICAL:** Test thoroughly before Monday! The laser is expensive - safety first! diff --git a/nuescan/apt_communications_protocol.pdf b/docs/protocols/apt_communications_protocol.pdf similarity index 100% rename from nuescan/apt_communications_protocol.pdf rename to docs/protocols/apt_communications_protocol.pdf diff --git a/nuescan/helios_comms_protocol.pdf b/docs/protocols/helios_comms_protocol.pdf old mode 100755 new mode 100644 similarity index 100% rename from nuescan/helios_comms_protocol.pdf rename to docs/protocols/helios_comms_protocol.pdf diff --git a/nuescan/output.pdf b/docs/protocols/thorlabs_mls_protocol.pdf similarity index 100% rename from nuescan/output.pdf rename to docs/protocols/thorlabs_mls_protocol.pdf diff --git a/hardware/__init__.py b/hardware/__init__.py new file mode 100644 index 0000000..e40b793 --- /dev/null +++ b/hardware/__init__.py @@ -0,0 +1,6 @@ +"""Hardware driver modules for ScanEngine-3""" +from .bbd202 import * +from .uc480_camera import * +from .tektronix_base import * +from .coherent_hops_laser import * +from .genesis_core import * diff --git a/pybbd202/bbd203_controller.py b/hardware/bbd202.py similarity index 83% rename from pybbd202/bbd203_controller.py rename to hardware/bbd202.py index 73c8643..eb8eadf 100644 --- a/pybbd202/bbd203_controller.py +++ b/hardware/bbd202.py @@ -58,6 +58,9 @@ class MsgId(IntEnum): MOT_GET_MOVEABSPARAMS = 0x0452 MOT_MOVE_STOP = 0x0465 MOT_MOVE_STOPPED = 0x0466 + MOT_SET_TRIGGER = 0x0500 + MOT_REQ_TRIGGER = 0x0501 + MOT_GET_TRIGGER = 0x0502 class ChannelEnableState(IntEnum): @@ -78,6 +81,16 @@ class StopMode(IntEnum): CONTROLLED = 0x02 +class TriggerMode(IntEnum): + """Trigger mode values.""" + DISABLED = 0x00 + IN_OUT_RELATIVE_MOVE = 0x01 + IN_OUT_ABSOLUTE_MOVE = 0x02 + IN_OUT_HOME = 0x03 + IN_OUT_STOP = 0x04 + OUT_ONLY = 0x10 + + class MotorStatusBits(IntFlag): """Motor status bit flags.""" CWHARDLIMIT = 0x00000001 # Clockwise hard limit triggered @@ -183,9 +196,10 @@ class MotionController: self._status_bits = {} # dest -> int (updated on every MOVE_COMPLETED or MOVE_STOPPED) self._status_bits_lock = threading.Lock() - # CRITICAL: Controller stops responding after ~50 commands without periodic ACK - self._ack_thread: Optional[threading.Thread] = None - self._ack_running = False + # TX queue for serialized command sending - all outgoing data goes through this queue + self._tx_queue: queue.Queue = queue.Queue() + self._tx_thread: Optional[threading.Thread] = None + self._tx_running = False self._rx_thread: Optional[threading.Thread] = None self._rx_running = False @@ -196,13 +210,33 @@ class MotionController: self._waiters: dict[int, tuple[threading.Event, list]] = {} self._waiter_lock = threading.Lock() + + # Pending move tracking for high-level motion control + self._pending_moves = {} # dest -> target_position (float) + self._pending_moves_lock = threading.Lock() + + # Last error tracking + self._last_error: Optional[str] = None + self._last_error_lock = threading.Lock() + + # Auto-ACK control - when True, ACKs are sent automatically in RX loop + # Set to False via stop_status_ack() for manual ACK mode during scanning + self._auto_ack_enabled = True + self._status_update_count_x = 0 # Track X status updates + self._status_update_count_y = 0 # Track Y status updates def connect(self, enable_updates: bool = True) -> None: """ Open connection to the controller. - + Args: - enable_updates: If True, enable status update messages and start listener thread + enable_updates: If True, enable status update messages. ACKs are sent + reactively when status updates are received. + + Note: + When enable_updates=True, the controller will send periodic status updates. + ACKs are automatically sent in response to each status update through + the TX queue, ensuring proper serialization with other commands. """ self.ftdi.open_from_url(self.url) self.ftdi.set_baudrate(self.baudrate) @@ -213,33 +247,47 @@ class MotionController: self._connected = True time.sleep(0.1) + # Start TX thread for serialized command sending + self._tx_running = True + self._tx_thread = threading.Thread(target=self._tx_loop, daemon=True) + self._tx_thread.start() + self._rx_running = True self._rx_thread = threading.Thread(target=self._rx_loop, daemon=True) self._rx_thread.start() if enable_updates: - self._send_raw(self._build_short_message(MsgId.HW_START_UPDATEMSGS)) + # Send to both axis channels to enable updates from each + self._send_raw(self._build_short_message(MsgId.HW_START_UPDATEMSGS, dest=self.DEST_X_AXIS)) + self._send_raw(self._build_short_message(MsgId.HW_START_UPDATEMSGS, dest=self.DEST_Y_AXIS)) else: - self._send_raw(self._build_short_message(MsgId.HW_STOP_UPDATEMSGS)) + self._send_raw(self._build_short_message(MsgId.HW_STOP_UPDATEMSGS, dest=self.DEST_X_AXIS)) + self._send_raw(self._build_short_message(MsgId.HW_STOP_UPDATEMSGS, dest=self.DEST_Y_AXIS)) time.sleep(0.1) def disconnect(self) -> None: """Close connection to the controller.""" if self._connected: - self.stop_status_ack() - self._rx_running = False if self._rx_thread: self._rx_thread.join(timeout=1.0) self._rx_thread = None try: - self._send_raw(self._build_short_message(MsgId.HW_DISCONNECT)) + # Send disconnect directly to FTDI since we're shutting down TX thread + with self._rx_lock: + self.ftdi.write_data(self._build_short_message(MsgId.HW_DISCONNECT)) time.sleep(0.05) except: pass + # Stop TX thread after sending disconnect + self._tx_running = False + if self._tx_thread: + self._tx_thread.join(timeout=1.0) + self._tx_thread = None + self.ftdi.close() self._connected = False self._hw_info = None @@ -257,11 +305,25 @@ class MotionController: return header + data def _send_raw(self, data: bytes) -> int: - """Send raw bytes to the controller.""" + """Queue raw bytes to be sent to the controller via the TX thread.""" if not self._connected: raise ConnectionError("Not connected to controller") - with self._rx_lock: - return self.ftdi.write_data(data) + self._tx_queue.put(data) + return len(data) + + def _tx_loop(self) -> None: + """TX thread - processes all outgoing commands from the queue.""" + while self._tx_running: + try: + data = self._tx_queue.get(timeout=0.1) + if data is not None: + with self._rx_lock: + self.ftdi.write_data(data) + except queue.Empty: + pass + except Exception as e: + if self._tx_running: + print(f"TX error: {e}") def _rx_loop(self) -> None: """Receiver thread loop - continuously reads and parses messages.""" @@ -359,6 +421,48 @@ class MotionController: self._encoder_counts[msg.source] = encoder_counts self._stage_positions[msg.source] = position_mm + # Process status updates - parse position and status data + if msg.msg_id == MsgId.MOT_GET_USTATUSUPDATE: + # Track per-axis counts + if msg.source == 0x21: + self._status_update_count_x += 1 + count = self._status_update_count_x + elif msg.source == 0x22: + self._status_update_count_y += 1 + count = self._status_update_count_y + else: + count = 0 + # Debug: print every 10th status update with position + if count > 0 and count % 10 == 0: + axis = "X" if msg.source == 0x21 else "Y" + if len(msg.data) >= 14: + pos_counts = struct.unpack('= 14: + position_counts = struct.unpack(' None: + """Request status updates from all axes (non-blocking).""" + # Request status from both axes + self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00, + dest=self.DEST_X_AXIS, source=0x01) + self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00, + dest=self.DEST_Y_AXIS, source=0x01) + + def request_status_update(self, dest: int) -> None: + """Request status update from specific axis (non-blocking).""" + self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00, + dest=dest, source=0x01) + + def check_for_errors(self) -> Optional[str]: + """Check if any axis has error flags set and store as last error.""" + error_msg = None + with self._status_bits_lock: + x_bits = self._status_bits.get(self.DEST_X_AXIS, 0) + y_bits = self._status_bits.get(self.DEST_Y_AXIS, 0) + + if x_bits & MotorStatusBits.ERROR: + error_msg = "X-axis error detected" + elif y_bits & MotorStatusBits.ERROR: + error_msg = "Y-axis error detected" + + if error_msg: + with self._last_error_lock: + self._last_error = error_msg + + return error_msg + + def clear_last_error(self) -> None: + """Clear the stored last error message.""" + with self._last_error_lock: + self._last_error = None + + @property + def last_error(self) -> Optional[str]: + """Get the last error message that was detected.""" + with self._last_error_lock: + return self._last_error + + def is_move_pending(self) -> bool: + """Check if any moves are pending.""" + with self._pending_moves_lock: + return len(self._pending_moves) > 0 + + def get_pending_targets(self) -> dict: + """Get pending move targets as dict mapping dest -> target_position.""" + with self._pending_moves_lock: + return dict(self._pending_moves) + # Velocity parameter properties @property @@ -1289,12 +1445,23 @@ class MotionController: Once started, the controller will periodically send status update messages containing position, velocity, and status information. These can be captured by registering a callback for the update message type. + + Note: On BBD202/BBD203, HW_START_UPDATEMSGS must be sent to each axis + channel (0x21, 0x22) rather than the generic destination (0x11). """ + # Send to both axis channels to enable updates from each self.send_command( MsgId.HW_START_UPDATEMSGS, param1=0x00, param2=0x00, - dest=0x11, # Generic destination + dest=self.DEST_X_AXIS, # 0x21 + source=0x01 + ) + self.send_command( + MsgId.HW_START_UPDATEMSGS, + param1=0x00, + param2=0x00, + dest=self.DEST_Y_AXIS, # 0x22 source=0x01 ) print("Started automatic status update messages") @@ -1315,83 +1482,42 @@ class MotionController: ) print("Stopped automatic status update messages") - def _ack_loop(self) -> None: - """ - Background thread that sends periodic ACK messages. - - CRITICAL: The controller will stop responding after ~50 commands if ACK - messages are not sent at least once per second. - """ - while self._ack_running: - try: - self.send_command( - MsgId.MOT_ACK_USTATUSUPDATE, - param1=0x00, - param2=0x00, - dest=0x11, # Generic destination - source=0x01 - ) - # Sleep for 1 second before next ACK - time.sleep(1.0) - except Exception as e: - if self._ack_running: - print(f"ACK loop error: {e}") - time.sleep(1.0) - def start_status_ack(self) -> None: """ - Start the periodic status ACK thread. + Enable automatic ACK mode. - CRITICAL: This MUST be called to prevent the controller from stopping - responses after ~50 commands. The ACK is sent every 1 second. + When enabled, ACKs are sent automatically in the RX loop whenever + a status update message is received from the controller. + + Use this after scanning operations that require manual ACK mode. """ - if self._ack_running: - return # Already running - - self._ack_running = True - self._ack_thread = threading.Thread(target=self._ack_loop, daemon=True) - self._ack_thread.start() - print("Started periodic status ACK (every 1 second)") + self._auto_ack_enabled = True def stop_status_ack(self) -> None: - """Stop the periodic status ACK thread.""" - if self._ack_running: - self._ack_running = False - if self._ack_thread: - self._ack_thread.join(timeout=2.0) - self._ack_thread = None - print("Stopped periodic status ACK") - - def request_status_update(self, dest: int) -> None: """ - Request a status update from the specified axis. + Disable automatic ACK mode (switch to manual ACK mode). - This sends MGMSG_MOT_REQ_USTATUSUPDATE which causes the controller to - respond with MGMSG_MOT_GET_USTATUSUPDATE (0x0491). + When disabled, ACKs are NOT sent automatically. You must call + ack_status_update() manually after each move completes. - Args: - dest: Destination address (0x21 for X-axis, 0x22 for Y-axis) - - Raises: - ValueError: If dest is invalid + Use this during scanning operations for more precise control over + when ACKs are sent. The snake test pattern is: + mc.stop_status_ack() + for each move: + mc.move_to_fast(...) + while not mc.poll_until_idle(...): + ... + mc.ack_status_update() # Manual ACK after move completes + mc.start_status_ack() """ - if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS): - raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)") - - self.send_command( - MsgId.MOT_REQ_USTATUSUPDATE, - param1=0x01, - param2=0x00, - dest=dest, - source=0x01 - ) + self._auto_ack_enabled = False def ack_status_update(self) -> None: """ - Send a single status update ACK. + Send a single status update ACK manually. - Normally you should use start_status_ack() to run the ACK automatically. - This method is for manual ACK control if needed. + Note: ACKs are now sent automatically in response to status update messages. + This method is retained for debugging or manual control if needed. """ self.send_command( MsgId.MOT_ACK_USTATUSUPDATE, @@ -1821,6 +1947,38 @@ class MotionController: if waiter_key in self._waiters: del self._waiters[waiter_key] + # Trigger configuration methods + + def set_trigger(self, dest: int, trigger_mode: int, polarity: int = 0x01) -> None: + """ + Configure trigger output for an axis. + + Args: + dest: Destination address (0x21 for X-axis, 0x22 for Y-axis) + trigger_mode: Trigger mode from TriggerMode enum + polarity: Trigger polarity (0x01 = active high, 0x02 = active low) + + Raises: + ValueError: If dest is invalid + """ + if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS): + raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)") + + # Build 14-byte trigger configuration packet + data = struct.pack(' None: @@ -2259,6 +2417,294 @@ class MotionController: self.stop_move(self.DEST_Y_AXIS, stop_mode, False) return {'x': None, 'y': None} + # High-level motion control methods + + def poll_positions(self) -> None: + """ + Request position updates for both X and Y axes. + + This actively queries the controller for the current positions of both axes. + The positions are stored internally and can be accessed via position_x and position_y properties. + """ + try: + self.get_position(self.DEST_X_AXIS, timeout=1.5) + except Exception as e: + print(f"Error polling X position: {e}") + + try: + self.get_position(self.DEST_Y_AXIS, timeout=1.5) + except Exception as e: + print(f"Error polling Y position: {e}") + + def move_to_fast(self, x: Optional[float] = None, y: Optional[float] = None) -> None: + """ + Move to absolute position(s) using fast non-blocking moves. + + Args: + x: Target X position in mm (None to leave X unchanged) + y: Target Y position in mm (None to leave Y unchanged) + """ + # Track pending moves + with self._pending_moves_lock: + if x is not None: + self._pending_moves[self.DEST_X_AXIS] = x + if y is not None: + self._pending_moves[self.DEST_Y_AXIS] = y + + if y is not None: + y_counts = int(y * self.ENCODER_COUNTS_PER_MM) + y_data = struct.pack(' bool: + """ + Move to position and wait for MOT_MOVE_COMPLETED message(s). + + This method sends absolute move commands and polls for MOT_MOVE_COMPLETED + messages by checking the _move_completed dictionary which is updated by + the RX thread when completion messages arrive. + + Args: + x: Target X position in mm, or None to skip X axis + y: Target Y position in mm, or None to skip Y axis + timeout: Maximum wait time in seconds (default 30s) + + Returns: + True if all moves completed successfully, False if timeout + """ + if x is None and y is None: + return True # Nothing to do + + # Determine which axes we're moving + axes_to_move = [] + if x is not None: + axes_to_move.append(self.DEST_X_AXIS) + if y is not None: + axes_to_move.append(self.DEST_Y_AXIS) + + # Clear any previous completion data for axes we're about to move + with self._move_completed_lock: + for dest in axes_to_move: + if dest in self._move_completed: + del self._move_completed[dest] + + # Track pending moves + with self._pending_moves_lock: + if x is not None: + self._pending_moves[self.DEST_X_AXIS] = x + if y is not None: + self._pending_moves[self.DEST_Y_AXIS] = y + + move_type = "X+Y" if (x is not None and y is not None) else "X-only" if x is not None else "Y-only" + print(f" [move_and_wait] {move_type}: x={x}, y={y}") + + # Send Y move first if needed + if y is not None: + y_counts = int(y * self.ENCODER_COUNTS_PER_MM) + y_data = struct.pack(' timeout: + missing = [("X" if d == self.DEST_X_AXIS else "Y") for d in axes_to_move if d not in completed_axes] + print(f" [move_and_wait] Timeout waiting for {', '.join(missing)} MOT_MOVE_COMPLETED") + return False + + # Check for completions + with self._move_completed_lock: + for dest in axes_to_move: + if dest not in completed_axes and dest in self._move_completed: + axis_name = "X" if dest == self.DEST_X_AXIS else "Y" + print(f" [move_and_wait] {axis_name}-axis move completed") + completed_axes.add(dest) + # Clear pending move + with self._pending_moves_lock: + if dest in self._pending_moves: + del self._pending_moves[dest] + + if len(completed_axes) < len(axes_to_move): + time.sleep(0.010) # 10ms poll interval + + return True + + def poll_until_idle(self, tolerance: float = 0.005, timeout: float = 0.1) -> bool: + """ + Poll positions and check if all pending moves are complete within tolerance. + + This method checks if the stage has settled at the target positions for all pending moves. + A move is considered complete when the current position is within 'tolerance' of the target. + + Args: + tolerance: Position tolerance in mm (default 0.005mm = 5 microns) + timeout: Time to spend polling in this call (default 0.1s) + + Returns: + True if all pending moves are complete (within tolerance), False otherwise + """ + # Get current pending targets (snapshot) + with self._pending_moves_lock: + pending = dict(self._pending_moves) + + if not pending: + # No pending moves + return True + + # Check if all axes are within tolerance + all_settled = True + completed_axes = [] + + # Query positions using get_position which properly waits for responses + # Use short timeout to avoid blocking too long + query_timeout = min(timeout, 0.5) + + for dest, target in pending.items(): + try: + # Get current position with timeout + current = self.get_position(dest, timeout=query_timeout) + + if current is None: + # Position query failed or timed out - not settled + all_settled = False + continue + + error = abs(current - target) + + if error <= tolerance: + # This axis is settled + completed_axes.append(dest) + else: + # Still moving + all_settled = False + + except Exception as e: + # Position query failed - assume not settled + print(f"Error querying position for axis 0x{dest:02X}: {e}") + all_settled = False + + # Remove completed axes from pending list + if completed_axes: + with self._pending_moves_lock: + for dest in completed_axes: + if dest in self._pending_moves: + del self._pending_moves[dest] + + return all_settled + + def poll_until_idle_passive(self, tolerance: float = 0.005, debug: bool = False) -> bool: + """ + Check if all pending moves are complete using cached positions from automatic updates. + + Unlike poll_until_idle(), this does NOT send position request commands. It only + reads the cached positions that are updated from automatic status updates + (MOT_GET_USTATUSUPDATE). This avoids command traffic that might interfere with + move execution. + + Args: + tolerance: Position tolerance in mm (default 0.005mm = 5 microns) + debug: If True, print debug info about positions + + Returns: + True if all pending moves are complete (within tolerance), False otherwise + """ + # Get current pending targets (snapshot) + with self._pending_moves_lock: + pending = dict(self._pending_moves) + + if not pending: + return True + + # Check if all axes are within tolerance using cached positions + all_settled = True + completed_axes = [] + + for dest, target in pending.items(): + # Get cached position (updated by automatic status updates) + with self._position_lock: + current = self._stage_positions.get(dest) + + axis_name = "X" if dest == self.DEST_X_AXIS else "Y" + + if current is None: + # No cached position yet - not settled + if debug: + print(f" [poll] {axis_name}: no cached position") + all_settled = False + continue + + error = abs(current - target) + + if debug: + print(f" [poll] {axis_name}: current={current:.3f}, target={target:.3f}, error={error*1000:.1f}um") + + if error <= tolerance: + completed_axes.append(dest) + else: + all_settled = False + + # Remove completed axes from pending list + if completed_axes: + with self._pending_moves_lock: + for dest in completed_axes: + if dest in self._pending_moves: + del self._pending_moves[dest] + + return all_settled + + def clear_pending_moves(self) -> None: + """ + Clear all pending move targets. + + This clears the internal tracking of pending moves. It does NOT stop the motors - + use stop_all_axes() if you want to halt motion. + """ + with self._pending_moves_lock: + self._pending_moves.clear() + # Status bit decoding methods @staticmethod diff --git a/hardware/coherent_hops_laser.py b/hardware/coherent_hops_laser.py new file mode 100644 index 0000000..aaf0c1d --- /dev/null +++ b/hardware/coherent_hops_laser.py @@ -0,0 +1,45 @@ +""" +Coherent HOPS Laser Driver - Stub Module +This is a temporary stub to allow testing camera integration. +""" + + +class CoherentHOPSLaser: + """Stub class for Coherent HOPS Laser""" + pass + + +class DummyLaser: + """Dummy laser for testing without hardware""" + + def connect(self): + """Simulate connection""" + pass + + def disconnect(self): + """Simulate disconnection""" + pass + + def get_hardware_id(self): + """Return simulated hardware ID""" + return "SIM-12345" + + def get_laser_model(self): + """Return simulated model""" + return "Genesis Simulator" + + def get_interlock_status(self): + """Return simulated interlock status""" + return "OK" + + def get_key_switch_status(self): + """Return simulated key switch status""" + return "ON" + + def get_temperature_main(self): + """Return simulated main temperature""" + return 25.5 + + def get_temperature_eta(self): + """Return simulated ETA temperature""" + return 26.3 diff --git a/hardware/genesis_core.py b/hardware/genesis_core.py new file mode 100644 index 0000000..d3c82aa --- /dev/null +++ b/hardware/genesis_core.py @@ -0,0 +1,669 @@ +""" +Genesis SLM MX 532 Laser Core Hardware Control Module +====================================================== + +This module provides low-level hardware control for the Genesis SLM MX 532 laser +using NXP I2C-over-serial protocol. It contains reusable classes for serial +communication, I2C protocol handling, device control, and laser operations. + +This module is GUI-independent and can be used by both command-line and GUI applications. + +Protocol Overview: +----------------- +The laser uses NXP I2C-over-serial protocol with packet format: +[0x53] [I2C_ADDR] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50] + +For reads: +[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50] + +I2C Devices: +----------- +- X9119 digital potentiometer at 0x52 - laser current control +- PCA9555 I/O expander at 0x4a - digital I/O +- AD5254 digital potentiometer at 0x58 - limits control +- ADS7828 ADC at 0x90 - sensor readings +- M24C64 EEPROM at 0xa4 - configuration storage + +Author: Claude +Date: 2026-01-24 +""" + +import time +from datetime import datetime +from typing import Optional, List +from enum import IntEnum + +import serial +from serial.tools import list_ports + + +# ============================================================================ +# Constants - I2C Addresses +# ============================================================================ + +class I2CAddress(IntEnum): + """I2C device addresses (7-bit addresses shifted left by 1)""" + # X9119 Digital Potentiometer (current control) + X9119_CURRENT_WRITE = 0x52 + X9119_CURRENT_READ = 0x53 + + # PCA9555 I/O Expanders + PCA9555_DIO_WRITE = 0x4a # Main control I/O at 0x14a + PCA9555_DIO_READ = 0x4b + PCA9555_PSGLUE_WRITE = 0x48 # Power supply glue at 0x148 + PCA9555_PSGLUE_READ = 0x49 + PCA9555_HEAD_WRITE = 0x44 # Head DIO at 0x144 + PCA9555_HEAD_READ = 0x45 + PCA9555_LDD_WRITE = 0x40 # LDD control at 0x140 + PCA9555_LDD_READ = 0x41 + + # AD5254 Digital Potentiometer (limits) + AD5254_WRITE = 0x58 + + # ADS7828 ADC (sensor readings) + ADS7828_WRITE = 0x90 + ADS7828_READ = 0x91 + + # M24C64 EEPROM (configuration) + EEPROM_WRITE = 0xa4 + EEPROM_READ = 0xa5 + + +# ============================================================================ +# Constants - PCA9555 Register Addresses +# ============================================================================ + +class PCA9555Register(IntEnum): + """PCA9555 I/O expander register addresses""" + INPUT_PORT_0 = 0x00 + INPUT_PORT_1 = 0x01 + OUTPUT_PORT_0 = 0x02 + OUTPUT_PORT_1 = 0x03 + POLARITY_INV_0 = 0x04 + POLARITY_INV_1 = 0x05 + CONFIG_PORT_0 = 0x06 + CONFIG_PORT_1 = 0x07 + + +# ============================================================================ +# Constants - Control Bitmasks +# ============================================================================ + +class ControlBitmask(IntEnum): + """Bitmasks for PCA9555 port 0 control bits""" + SHUTTER = 0x01 # Bit 0 + CURRENT_MODE = 0x04 # Bit 2 + REMOTE_ENABLE = 0x08 # Bit 3 + ANALOG_ENABLE = 0x10 # Bit 4 + KEYSWITCH = 0x20 # Bit 5 + INTERLOCK = 0x01 # Bit 0 (on different PCA9555) + LDD_ENABLE = 0x01 # Bit 0 (on LDD PCA9555) + + +# ============================================================================ +# Serial Communication Layer +# ============================================================================ + +class SerialComm: + """Handles low-level serial port communication""" + + def __init__(self): + self.port: Optional[serial.Serial] = None + self.port_name: str = "/dev/ttyUSB0" + self.baudrate: int = 9600 + self.timeout: float = 1.0 + self.packet_log: List[str] = [] + + def connect(self, port_name: str, baudrate: int = 9600) -> bool: + """ + Connect to serial port + + Args: + port_name: Serial port device name + baudrate: Baud rate (default 9600) + + Returns: + True if successful, False otherwise + """ + try: + self.port_name = port_name + self.baudrate = baudrate + self.port = serial.Serial( + port=port_name, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=self.timeout + ) + time.sleep(0.1) # Allow port to stabilize + return True + except Exception as e: + print(f"Serial connection error: {e}") + return False + + def disconnect(self): + """Disconnect from serial port""" + if self.port and self.port.is_open: + self.port.close() + self.port = None + + def is_connected(self) -> bool: + """Check if serial port is connected""" + return self.port is not None and self.port.is_open + + def write(self, data: bytes) -> bool: + """ + Write data to serial port + + Args: + data: Bytes to write + + Returns: + True if successful, False otherwise + """ + if not self.is_connected(): + return False + + try: + self.port.write(data) + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + hex_str = " ".join(f"{b:02x}" for b in data) + self.packet_log.append(f"[{timestamp}] TX: {hex_str}") + return True + except Exception as e: + print(f"Serial write error: {e}") + return False + + def read(self, size: int) -> Optional[bytes]: + """ + Read data from serial port + + Args: + size: Number of bytes to read + + Returns: + Bytes read or None on error + """ + if not self.is_connected(): + return None + + try: + data = self.port.read(size) + if data: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + hex_str = " ".join(f"{b:02x}" for b in data) + self.packet_log.append(f"[{timestamp}] RX: {hex_str}") + return data + except Exception as e: + print(f"Serial read error: {e}") + return None + + def flush(self): + """Flush serial port buffers""" + if self.is_connected(): + self.port.reset_input_buffer() + self.port.reset_output_buffer() + + def get_packet_log(self, last_n: int = 100) -> List[str]: + """Get last N entries from packet log""" + return self.packet_log[-last_n:] + + def clear_packet_log(self): + """Clear packet log""" + self.packet_log.clear() + + +# ============================================================================ +# I2C Protocol Layer +# ============================================================================ + +class I2CProtocol: + """Handles NXP I2C-over-serial protocol packet construction""" + + NXP_START = 0x53 + NXP_STOP = 0x50 + + def __init__(self, serial_comm: SerialComm): + self.serial = serial_comm + + def write(self, i2c_addr_write: int, cmd: bytes, data: bytes = b'') -> bool: + """ + Build and send NXP I2C write packet + + Packet format: [0x53] [I2C_ADDR] [LENGTH] [COMMAND] [DATA] [0x50] + + Args: + i2c_addr_write: I2C write address + cmd: Command bytes + data: Data bytes (optional) + + Returns: + True if successful, False otherwise + """ + if not isinstance(cmd, bytes): + cmd = bytes([cmd]) + if not isinstance(data, bytes): + data = bytes(data) + + length = len(cmd) + len(data) + packet = bytes([self.NXP_START, i2c_addr_write, length]) + cmd + data + bytes([self.NXP_STOP]) + + return self.serial.write(packet) + + def read(self, i2c_addr_write: int, cmd: bytes, cmd_len: int, data_len: int) -> Optional[bytes]: + """ + Build and send NXP I2C read packet, return data + + Packet format for read: + [0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50] + + Args: + i2c_addr_write: I2C write address + cmd: Command bytes + cmd_len: Length of command + data_len: Expected data length to read + + Returns: + Data bytes read or None on error + """ + if not isinstance(cmd, bytes): + cmd = bytes([cmd]) + + i2c_addr_read = i2c_addr_write | 0x01 # Set read bit + + # First part: write command + packet_write = bytes([self.NXP_START, i2c_addr_write, cmd_len]) + cmd + + # Second part: read data + packet_read = bytes([self.NXP_START, i2c_addr_read, data_len, self.NXP_STOP]) + + packet = packet_write + packet_read + + if not self.serial.write(packet): + return None + + # Read response + # Expected response: [data_bytes] + time.sleep(0.05) # Give device time to respond + response = self.serial.read(data_len) + + return response if response and len(response) == data_len else None + + +# ============================================================================ +# I2C Device Functions +# ============================================================================ + +class I2CDevices: + """High-level I2C device control functions""" + + def __init__(self, protocol: I2CProtocol): + self.protocol = protocol + + # ------------------------------------------------------------------------ + # PCA9555 I/O Expander Functions + # ------------------------------------------------------------------------ + + def pca9555_read_port(self, addr_write: int, port: int) -> Optional[int]: + """ + Read PCA9555 port register + + Args: + addr_write: I2C write address + port: Register address (0-7) + + Returns: + Port value (0-255) or None on error + """ + data = self.protocol.read(addr_write, bytes([port]), 1, 1) + return data[0] if data else None + + def pca9555_write_port(self, addr_write: int, port: int, value: int) -> bool: + """ + Write PCA9555 port register + + Args: + addr_write: I2C write address + port: Register address (0-7) + value: Value to write (0-255) + + Returns: + True if successful, False otherwise + """ + return self.protocol.write(addr_write, bytes([port]), bytes([value])) + + def pca9555_set_bit(self, addr_write: int, port: int, bitmask: int, state: bool) -> bool: + """ + Set or clear specific bit on PCA9555 + + Args: + addr_write: I2C write address + port: Register address + bitmask: Bit mask (e.g., 0x01 for bit 0) + state: True to set bit, False to clear + + Returns: + True if successful, False otherwise + """ + # Read current value + current = self.pca9555_read_port(addr_write, port) + if current is None: + return False + + # Modify bit + if state: + new_value = current | bitmask + else: + new_value = current & ~bitmask + + # Write back + return self.pca9555_write_port(addr_write, port, new_value) + + # ------------------------------------------------------------------------ + # X9119 Digital Potentiometer Functions + # ------------------------------------------------------------------------ + + def x9119_write_wiper(self, addr_write: int, value: int) -> bool: + """ + Set X9119 wiper position + + Args: + addr_write: I2C write address + value: Wiper position (0-1023, 10-bit) + + Returns: + True if successful, False otherwise + """ + # X9119 write wiper command: 0xa0 followed by 2 bytes (10-bit value) + # Value is split into two bytes: MSB contains upper 2 bits, LSB contains lower 8 bits + value = max(0, min(1023, value)) # Clamp to valid range + + msb = (value >> 8) & 0x03 # Upper 2 bits + lsb = value & 0xFF # Lower 8 bits + + cmd = bytes([0xa0]) + data = bytes([msb, lsb]) + + return self.protocol.write(addr_write, cmd, data) + + # ------------------------------------------------------------------------ + # ADS7828 ADC Functions + # ------------------------------------------------------------------------ + + def ads7828_read(self, addr_write: int, channel: int) -> Optional[int]: + """ + Read ADS7828 ADC channel + + Args: + addr_write: I2C write address + channel: Channel to read (0-7) + + Returns: + 12-bit ADC value (0-4095) or None on error + """ + # ADS7828 command byte format: + # Bit 7: SD (single-ended=1) + # Bits 6-4: Channel select + # Bit 3: PD1 (power-down mode) + # Bit 2: PD0 (power-down mode) + # Bits 1-0: Don't care + + # For single-ended read with internal reference on + cmd_byte = 0x80 | ((channel & 0x07) << 4) | 0x0c + + data = self.protocol.read(addr_write, bytes([cmd_byte]), 1, 2) + if not data or len(data) != 2: + return None + + # Combine two bytes into 12-bit value + value = (data[0] << 8) | data[1] + value = (value >> 4) & 0x0FFF # Extract 12-bit value + + return value + + +# ============================================================================ +# Laser Control Layer +# ============================================================================ + +class LaserControl: + """High-level laser control functions""" + + def __init__(self, devices: I2CDevices): + self.devices = devices + self._safe_state_active = False + + # ------------------------------------------------------------------------ + # SET Commands + # ------------------------------------------------------------------------ + + def set_current(self, value: int) -> bool: + """ + Set laser current command (CCMD=) + + Args: + value: Current value (0-1023) + + Returns: + True if successful, False otherwise + """ + return self.devices.x9119_write_wiper(I2CAddress.X9119_CURRENT_WRITE, value) + + def set_power_cmd(self, value: int) -> bool: + """ + Set power command (PCMD=) + + Note: Uses same device as current control, different channel + + Args: + value: Power value (0-1023) + + Returns: + True if successful, False otherwise + """ + return self.devices.x9119_write_wiper(I2CAddress.X9119_CURRENT_WRITE, value) + + def set_shutter(self, state: bool) -> bool: + """ + Set shutter state (SHCMD=) + + Args: + state: True for open, False for closed + + Returns: + True if successful, False otherwise + """ + return self.devices.pca9555_set_bit( + I2CAddress.PCA9555_DIO_WRITE, + PCA9555Register.OUTPUT_PORT_0, + ControlBitmask.SHUTTER, + state + ) + + def set_keyswitch(self, state: bool) -> bool: + """ + Set keyswitch state (KSWCMD=) + + Args: + state: True for on, False for off + + Returns: + True if successful, False otherwise + """ + return self.devices.pca9555_set_bit( + I2CAddress.PCA9555_DIO_WRITE, + PCA9555Register.OUTPUT_PORT_0, + ControlBitmask.KEYSWITCH, + state + ) + + def set_current_mode(self, state: bool) -> bool: + """ + Set current mode (CMODECMD=) + + Args: + state: True for on, False for off + + Returns: + True if successful, False otherwise + """ + return self.devices.pca9555_set_bit( + I2CAddress.PCA9555_DIO_WRITE, + PCA9555Register.OUTPUT_PORT_0, + ControlBitmask.CURRENT_MODE, + state + ) + + def set_analog_enable(self, state: bool) -> bool: + """ + Set analog input enable (ANACMD=) + + Args: + state: True for enabled, False for disabled + + Returns: + True if successful, False otherwise + """ + return self.devices.pca9555_set_bit( + I2CAddress.PCA9555_DIO_WRITE, + PCA9555Register.OUTPUT_PORT_0, + ControlBitmask.ANALOG_ENABLE, + state + ) + + def set_remote_enable(self, state: bool) -> bool: + """ + Set remote enable (REM=) + + Args: + state: True for enabled, False for disabled + + Returns: + True if successful, False otherwise + """ + return self.devices.pca9555_set_bit( + I2CAddress.PCA9555_DIO_WRITE, + PCA9555Register.OUTPUT_PORT_0, + ControlBitmask.REMOTE_ENABLE, + state + ) + + # ------------------------------------------------------------------------ + # GET/Query Commands + # ------------------------------------------------------------------------ + + def get_current_actual(self) -> Optional[float]: + """ + Get actual current reading + + Returns: + Current in arbitrary units (0-4095) or None on error + """ + # Read from ADS7828, channel based on command 0x84 + # Command 0x84 suggests channel 0 + value = self.devices.ads7828_read(I2CAddress.ADS7828_WRITE, 0) + return value if value is not None else None + + def get_interlock_status(self) -> Optional[bool]: + """ + Get interlock status + + Returns: + True if interlock OK, False if fault, None on error + """ + value = self.devices.pca9555_read_port( + I2CAddress.PCA9555_PSGLUE_WRITE, + PCA9555Register.INPUT_PORT_0 + ) + if value is None: + return None + + # Bit 0 indicates interlock status + return bool(value & ControlBitmask.INTERLOCK) + + def get_ldd_enable_status(self) -> Optional[bool]: + """ + Get laser diode driver enable status + + Returns: + True if enabled, False if disabled, None on error + """ + value = self.devices.pca9555_read_port( + I2CAddress.PCA9555_LDD_WRITE, + PCA9555Register.INPUT_PORT_0 + ) + if value is None: + return None + + return bool(value & ControlBitmask.LDD_ENABLE) + + def get_psglue_in_status(self) -> Optional[int]: + """ + Get power supply glue input status + + Returns: + Port value or None on error + """ + return self.devices.pca9555_read_port( + I2CAddress.PCA9555_PSGLUE_WRITE, + PCA9555Register.INPUT_PORT_0 + ) + + def get_psglue_out_status(self) -> Optional[int]: + """ + Get power supply glue output status + + Returns: + Port value or None on error + """ + return self.devices.pca9555_read_port( + I2CAddress.PCA9555_DIO_WRITE, + PCA9555Register.OUTPUT_PORT_0 + ) + + def get_head_dio_status(self) -> Optional[int]: + """ + Get head DIO status + + Returns: + Port value or None on error + """ + return self.devices.pca9555_read_port( + I2CAddress.PCA9555_HEAD_WRITE, + PCA9555Register.INPUT_PORT_0 + ) + + # ------------------------------------------------------------------------ + # Safety Functions + # ------------------------------------------------------------------------ + + def emergency_stop(self) -> bool: + """ + Emergency stop: close shutter, set current to 0, disable keyswitch + + Returns: + True if all operations successful, False otherwise + """ + success = True + success &= self.set_shutter(False) + success &= self.set_current(0) + success &= self.set_keyswitch(False) + self._safe_state_active = True + return success + + def enter_safe_state(self) -> bool: + """ + Enter safe state (similar to emergency stop but also disables remote) + + Returns: + True if successful, False otherwise + """ + success = True + success &= self.set_shutter(False) + success &= self.set_current(0) + success &= self.set_power_cmd(0) + success &= self.set_keyswitch(False) + success &= self.set_remote_enable(False) + self._safe_state_active = True + return success diff --git a/hardware/helios_laser.py b/hardware/helios_laser.py new file mode 100644 index 0000000..70b8c35 --- /dev/null +++ b/hardware/helios_laser.py @@ -0,0 +1,296 @@ +""" +Helios Laser System Driver +Basic implementation for controlling the Helios pulsed laser. +""" + +import serial +import time +import logging +from typing import Optional, List +from enum import Enum + +logger = logging.getLogger(__name__) + + +class PulseMode(Enum): + """Helios pulse mode enumeration""" + SINGLE_PULSE = 0 + CONTINUOUS_GATING = 1 + CONTINUOUS_PULSING = 2 + + +class HeliosLaser: + """ + Driver for Helios pulsed laser system. + + Communication: RS-232, 9600 baud, 8N1 + Commands are ASCII strings terminated with CR + """ + + def __init__(self, port: str = None, timeout: float = 1.0): + """ + Initialize Helios laser driver. + + Args: + port: Serial port (e.g., '/dev/ttyUSB0' or 'COM5') + timeout: Serial timeout in seconds + """ + self.port = port + self.timeout = timeout + self.serial = None + self.is_connected = False + + @staticmethod + def list_available_ports() -> List[str]: + """List available serial ports""" + import serial.tools.list_ports + ports = serial.tools.list_ports.comports() + return [port.device for port in ports] + + def connect(self, port: str = None) -> bool: + """ + Connect to the Helios laser. + + Args: + port: Serial port (uses stored port if None) + + Returns: + True if connection successful + """ + if port: + self.port = port + + if not self.port: + logger.error("No port specified") + return False + + try: + self.serial = serial.Serial( + port=self.port, + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=self.timeout + ) + time.sleep(0.1) # Allow time for connection to stabilize + self.is_connected = True + logger.info(f"Connected to Helios laser on {self.port}") + return True + + except Exception as e: + logger.error(f"Failed to connect to Helios laser: {e}") + self.is_connected = False + return False + + def disconnect(self): + """Disconnect from the laser""" + if self.serial and self.serial.is_open: + try: + # Disable laser before disconnecting + self.set_laser_enable(False) + self.serial.close() + logger.info("Disconnected from Helios laser") + except Exception as e: + logger.error(f"Error during disconnect: {e}") + + self.is_connected = False + self.serial = None + + def _send_command(self, command: str) -> bool: + """ + Send a command to the laser. + + Args: + command: ASCII command string (without CR) + + Returns: + True if sent successfully + """ + if not self.is_connected or not self.serial: + logger.error("Not connected to laser") + return False + + try: + cmd_bytes = (command + '\r').encode('ascii') + self.serial.write(cmd_bytes) + logger.debug(f"Sent command: {command}") + return True + + except Exception as e: + logger.error(f"Failed to send command '{command}': {e}") + return False + + def _query(self, command: str) -> Optional[str]: + """ + Send a query and read response. + + Args: + command: ASCII query command (without CR) + + Returns: + Response string or None if error + """ + if not self._send_command(command): + return None + + try: + response = self.serial.readline().decode('ascii').strip() + logger.debug(f"Query '{command}' response: {response}") + return response + + except Exception as e: + logger.error(f"Failed to read response for '{command}': {e}") + return None + + def set_frequency_hz(self, frequency: int) -> bool: + """ + Set laser pulse frequency in Hz. + + Args: + frequency: Frequency in Hz (16700 - 125000) + + Returns: + True if successful + """ + if not (16700 <= frequency <= 125000): + logger.error(f"Frequency {frequency} Hz out of range (16700-125000)") + return False + + # Convert frequency to period in nanoseconds + period_ns = int(1e9 / frequency) + + command = f"FP={period_ns}" + return self._send_command(command) + + def set_current_ma(self, current: int) -> bool: + """ + Set pump diode current in mA. + + Args: + current: Current in mA (0 - 7000) + + Returns: + True if successful + """ + if not (0 <= current <= 7000): + logger.error(f"Current {current} mA out of range (0-7000)") + return False + + command = f"PC={current}" + return self._send_command(command) + + def set_pulse_mode(self, mode: PulseMode) -> bool: + """ + Set pulse mode. + + Args: + mode: PulseMode enumeration value + + Returns: + True if successful + """ + command = f"PM={mode.value}" + return self._send_command(command) + + def set_laser_enable(self, enable: bool) -> bool: + """ + Enable or disable laser emission. + + Args: + enable: True to enable, False to disable + + Returns: + True if successful + """ + command = f"LE={1 if enable else 0}" + success = self._send_command(command) + + if success: + state = "enabled" if enable else "disabled" + logger.info(f"Laser {state}") + + return success + + def is_laser_enabled(self) -> bool: + """ + Check if laser is currently enabled. + + Returns: + True if laser is enabled + """ + response = self._query("LE?") + if response: + try: + return int(response) == 1 + except ValueError: + logger.error(f"Invalid response for LE?: {response}") + return False + + def get_frequency_hz(self) -> Optional[int]: + """ + Get current laser frequency in Hz. + + Returns: + Frequency in Hz or None if error + """ + response = self._query("FP?") + if response: + try: + period_ns = int(response) + return int(1e9 / period_ns) + except (ValueError, ZeroDivisionError): + logger.error(f"Invalid response for FP?: {response}") + return None + + def get_current_ma(self) -> Optional[int]: + """ + Get current pump diode current in mA. + + Returns: + Current in mA or None if error + """ + response = self._query("PC?") + if response: + try: + return int(response) + except ValueError: + logger.error(f"Invalid response for PC?: {response}") + return None + + def get_power_mw(self) -> Optional[float]: + """ + Get laser output power in mW. + + Returns: + Power in mW or None if error + """ + response = self._query("PO?") + if response: + try: + return float(response) + except ValueError: + logger.error(f"Invalid response for PO?: {response}") + return None + + def get_controller_serial(self) -> Optional[str]: + """ + Get controller serial number. + + Returns: + Serial number string or None if error + """ + return self._query("SN?") + + def get_head_serial(self) -> Optional[str]: + """ + Get laser head serial number. + + Returns: + Serial number string or None if error + """ + return self._query("HSN?") + + def __del__(self): + """Destructor - ensure cleanup""" + self.disconnect() diff --git a/pymso/tektronix_base.py b/hardware/tektronix_base.py similarity index 100% rename from pymso/tektronix_base.py rename to hardware/tektronix_base.py diff --git a/hardware/uc480_camera.py b/hardware/uc480_camera.py new file mode 100644 index 0000000..2125f6a --- /dev/null +++ b/hardware/uc480_camera.py @@ -0,0 +1,475 @@ +""" +uC480 Camera Driver +Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library. +Provides camera control, live streaming, and image capture capabilities. +""" + +import numpy as np +from pyueye import ueye +from PyQt6.QtCore import QThread, pyqtSignal, QObject +from PyQt6.QtGui import QImage +import logging +from typing import Optional, Tuple + +logger = logging.getLogger(__name__) + + +class UC480Camera(QObject): + """ + Driver class for uC480 camera. + Handles initialization, configuration, and image acquisition. + """ + + # Signals + frame_ready = pyqtSignal(QImage) # Emitted when a new frame is captured + error_occurred = pyqtSignal(str) # Emitted when an error occurs + + def __init__(self, camera_id: int = 0): + """ + Initialize the uC480 camera driver. + + Args: + camera_id: Camera ID (0 for first available camera) + """ + super().__init__() + + self.camera_id = camera_id + self.h_cam = ueye.HIDS(camera_id) + self.is_initialized = False + self.is_capturing = False + + # Memory and image info + self.mem_ptr = ueye.c_mem_p() + self.mem_id = ueye.int() + self.pitch = ueye.INT() + + # Camera info + self.sensor_info = ueye.SENSORINFO() + self.cam_info = ueye.CAMINFO() + self.rect_aoi = ueye.IS_RECT() + + # Image dimensions + self.width = 0 + self.height = 0 + self.bits_per_pixel = 24 # Default to 24-bit color + self.bytes_per_pixel = 3 + self.color_mode = ueye.IS_CM_BGR8_PACKED + + def initialize(self) -> bool: + """ + Initialize the camera and allocate memory. + + Returns: + True if successful, False otherwise + """ + try: + # Initialize camera + ret = ueye.is_InitCamera(self.h_cam, None) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to initialize camera: {ret}") + self.error_occurred.emit(f"Failed to initialize camera: {ret}") + return False + + # Get sensor info + ret = ueye.is_GetSensorInfo(self.h_cam, self.sensor_info) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to get sensor info: {ret}") + self.cleanup() + return False + + # Get camera info + ret = ueye.is_GetCameraInfo(self.h_cam, self.cam_info) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to get camera info: {ret}") + self.cleanup() + return False + + # Set color mode + ret = ueye.is_SetColorMode(self.h_cam, self.color_mode) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to set color mode: {ret}") + self.cleanup() + return False + + # Get maximum image size + self.width = self.sensor_info.nMaxWidth.value + self.height = self.sensor_info.nMaxHeight.value + + # Set Area of Interest (AOI) to maximum size + self.rect_aoi.s32X = ueye.int(0) + self.rect_aoi.s32Y = ueye.int(0) + self.rect_aoi.s32Width = ueye.int(self.width) + self.rect_aoi.s32Height = ueye.int(self.height) + + ret = ueye.is_AOI(self.h_cam, ueye.IS_AOI_IMAGE_SET_AOI, self.rect_aoi, ueye.sizeof(self.rect_aoi)) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to set AOI: {ret}") + self.cleanup() + return False + + # Allocate image memory + ret = ueye.is_AllocImageMem( + self.h_cam, + self.width, + self.height, + self.bits_per_pixel, + self.mem_ptr, + self.mem_id + ) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to allocate image memory: {ret}") + self.cleanup() + return False + + # Set active memory + ret = ueye.is_SetImageMem(self.h_cam, self.mem_ptr, self.mem_id) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to set active memory: {ret}") + self.cleanup() + return False + + # Get pitch (bytes per line) + ret = ueye.is_GetImageMemPitch(self.h_cam, self.pitch) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to get pitch: {ret}") + self.cleanup() + return False + + self.is_initialized = True + logger.info(f"Camera initialized: {self.width}x{self.height}, {self.bits_per_pixel}bpp") + + # Set default settings + self.set_exposure(10.0) # 10ms default exposure + self.set_pixel_clock(30) # 30MHz default pixel clock + self.set_framerate(30.0) # 30fps default + + return True + + except Exception as e: + logger.error(f"Exception during camera initialization: {e}") + self.error_occurred.emit(f"Exception during initialization: {e}") + self.cleanup() + return False + + def cleanup(self): + """Release camera resources.""" + if self.is_capturing: + self.stop_capture() + + if self.mem_ptr: + ueye.is_FreeImageMem(self.h_cam, self.mem_ptr, self.mem_id) + self.mem_ptr = None + + if self.is_initialized: + ueye.is_ExitCamera(self.h_cam) + self.is_initialized = False + logger.info("Camera resources released") + + def start_capture(self) -> bool: + """ + Start continuous video capture. + + Returns: + True if successful, False otherwise + """ + if not self.is_initialized: + logger.error("Camera not initialized") + return False + + if self.is_capturing: + logger.warning("Camera already capturing") + return True + + ret = ueye.is_CaptureVideo(self.h_cam, ueye.IS_DONT_WAIT) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to start capture: {ret}") + self.error_occurred.emit(f"Failed to start capture: {ret}") + return False + + self.is_capturing = True + logger.info("Video capture started") + return True + + def stop_capture(self) -> bool: + """ + Stop continuous video capture. + + Returns: + True if successful, False otherwise + """ + if not self.is_capturing: + return True + + ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT) + if ret != ueye.IS_SUCCESS: + logger.error(f"Failed to stop capture: {ret}") + return False + + self.is_capturing = False + logger.info("Video capture stopped") + return True + + def get_frame(self) -> Optional[QImage]: + """ + Capture a single frame from the camera. + + Returns: + QImage if successful, None otherwise + """ + if not self.is_initialized: + logger.error("Camera not initialized") + return None + + # Create numpy array from image memory + try: + array = ueye.get_data( + self.mem_ptr, + self.width, + self.height, + self.bits_per_pixel, + self.pitch, + copy=True + ) + + # Reshape to image dimensions + frame = np.reshape(array, (self.height, self.width, self.bytes_per_pixel)) + + # Convert to QImage (BGR to RGB) + height, width, channel = frame.shape + bytes_per_line = self.bytes_per_pixel * width + + # Convert BGR to RGB + rgb_frame = frame[:, :, ::-1].copy() + + q_image = QImage( + rgb_frame.data, + width, + height, + bytes_per_line, + QImage.Format.Format_RGB888 + ) + + # Make a copy since the numpy array will be deleted + return q_image.copy() + + except Exception as e: + logger.error(f"Failed to get frame: {e}") + self.error_occurred.emit(f"Failed to get frame: {e}") + return None + + def set_exposure(self, exposure_ms: float) -> bool: + """ + Set camera exposure time. + + Args: + exposure_ms: Exposure time in milliseconds + + Returns: + True if successful, False otherwise + """ + if not self.is_initialized: + return False + + exposure = ueye.c_double(exposure_ms) + ret = ueye.is_Exposure( + self.h_cam, + ueye.IS_EXPOSURE_CMD_SET_EXPOSURE, + exposure, + ueye.sizeof(exposure) + ) + + if ret == ueye.IS_SUCCESS: + logger.debug(f"Exposure set to {exposure_ms}ms") + return True + else: + logger.error(f"Failed to set exposure: {ret}") + return False + + def get_exposure(self) -> Optional[float]: + """ + Get current exposure time. + + Returns: + Exposure time in milliseconds, or None if failed + """ + if not self.is_initialized: + return None + + exposure = ueye.c_double() + ret = ueye.is_Exposure( + self.h_cam, + ueye.IS_EXPOSURE_CMD_GET_EXPOSURE, + exposure, + ueye.sizeof(exposure) + ) + + if ret == ueye.IS_SUCCESS: + return exposure.value + else: + return None + + def set_pixel_clock(self, pixel_clock_mhz: int) -> bool: + """ + Set camera pixel clock. + + Args: + pixel_clock_mhz: Pixel clock in MHz + + Returns: + True if successful, False otherwise + """ + if not self.is_initialized: + return False + + ret = ueye.is_PixelClock( + self.h_cam, + ueye.IS_PIXELCLOCK_CMD_SET, + ueye.c_uint(pixel_clock_mhz), + ueye.sizeof(ueye.c_uint) + ) + + if ret == ueye.IS_SUCCESS: + logger.debug(f"Pixel clock set to {pixel_clock_mhz}MHz") + return True + else: + logger.error(f"Failed to set pixel clock: {ret}") + return False + + def set_framerate(self, fps: float) -> bool: + """ + Set camera framerate. + + Args: + fps: Frames per second + + Returns: + True if successful, False otherwise + """ + if not self.is_initialized: + return False + + new_fps = ueye.c_double(fps) + actual_fps = ueye.c_double() + ret = ueye.is_SetFrameRate(self.h_cam, new_fps, actual_fps) + + if ret == ueye.IS_SUCCESS: + logger.debug(f"Framerate set to {fps}fps") + return True + else: + logger.error(f"Failed to set framerate: {ret}") + return False + + def get_framerate(self) -> Optional[float]: + """ + Get current framerate. + + Returns: + Framerate in fps, or None if failed + """ + if not self.is_initialized: + return None + + fps = ueye.c_double() + ret = ueye.is_GetFramesPerSecond(self.h_cam, fps) + + if ret == ueye.IS_SUCCESS: + return fps.value + else: + return None + + def set_gain(self, master_gain: int) -> bool: + """ + Set camera master gain. + + Args: + master_gain: Gain value (0-100) + + Returns: + True if successful, False otherwise + """ + if not self.is_initialized: + return False + + if master_gain < 0 or master_gain > 100: + logger.error(f"Gain value {master_gain} out of range (0-100)") + return False + + ret = ueye.is_SetHardwareGain( + self.h_cam, + master_gain, + ueye.IS_IGNORE_PARAMETER, + ueye.IS_IGNORE_PARAMETER, + ueye.IS_IGNORE_PARAMETER + ) + + if ret == ueye.IS_SUCCESS: + logger.debug(f"Master gain set to {master_gain}") + return True + else: + logger.error(f"Failed to set gain: {ret}") + return False + + def get_sensor_info(self) -> dict: + """ + Get camera sensor information. + + Returns: + Dictionary with sensor information + """ + if not self.is_initialized: + return {} + + return { + 'sensor_name': self.sensor_info.strSensorName.decode('utf-8'), + 'max_width': self.sensor_info.nMaxWidth.value, + 'max_height': self.sensor_info.nMaxHeight.value, + 'color_mode': self.sensor_info.nColorMode.value, + 'pixel_size': self.sensor_info.wPixelSize.value / 100.0, # in µm + } + + def __del__(self): + """Destructor - ensure cleanup.""" + self.cleanup() + + +class CameraStreamThread(QThread): + """ + Thread for continuous camera frame acquisition and streaming. + """ + + frame_ready = pyqtSignal(QImage) + error_occurred = pyqtSignal(str) + + def __init__(self, camera: UC480Camera): + """ + Initialize the camera stream thread. + + Args: + camera: UC480Camera instance + """ + super().__init__() + self.camera = camera + self.running = False + + def run(self): + """Main thread loop for frame acquisition.""" + self.running = True + + if not self.camera.start_capture(): + self.error_occurred.emit("Failed to start camera capture") + return + + while self.running: + frame = self.camera.get_frame() + if frame is not None: + self.frame_ready.emit(frame) + else: + # Small delay on error to prevent CPU spinning + self.msleep(10) + + self.camera.stop_capture() + + def stop(self): + """Stop the streaming thread.""" + self.running = False + self.wait() diff --git a/nuescan/.gitignore b/nuescan/.gitignore deleted file mode 100644 index b7faf40..0000000 --- a/nuescan/.gitignore +++ /dev/null @@ -1,207 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ diff --git a/nuescan/README.md b/nuescan/README.md deleted file mode 100644 index 906fa09..0000000 --- a/nuescan/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# nuescan -SRAS Scan Planning and Control Software diff --git a/nuescan/SETUP.md b/nuescan/SETUP.md deleted file mode 100644 index f8bbf25..0000000 --- a/nuescan/SETUP.md +++ /dev/null @@ -1,161 +0,0 @@ -# nueScan Setup Guide - -## Installation - -### Prerequisites -- Python 3.8 or higher -- pip package manager - -### Install Dependencies - -```bash -pip install -r requirements.txt -``` - -## Running the Application - -### Method 1: Run as module -```bash -python -m nuescan -``` - -### Method 2: Run __main__.py directly -```bash -python __main__.py -``` - -## Project Structure - -``` -nuescan/ -├── __main__.py # Application entry point -├── main_window.py # Main window controller -├── requirements.txt # Python dependencies -├── SETUP.md # This file -│ -├── dialogs/ # Dialog controllers -│ ├── __init__.py -│ ├── genesis_dialog.py # Genesis settings dialog -│ ├── helios_dialog.py # Helios settings dialog -│ └── scan_active_dialog.py # Scan progress dialog -│ -├── hardware/ # Hardware interface stubs -│ ├── __init__.py -│ ├── thorlabs_stage.py # ThorLabs MLS stage controller -│ ├── t3r_device.py # T3R-SL device controller -│ └── microscope.py # Genesis/Helios controller -│ -└── *.ui # Qt Designer UI files -``` - -## Hardware Interfaces - -### Current Implementation Status - -Hardware module implementation status: -- **ThorLabs BBD203 Stage**: ✅ **FULLY IMPLEMENTED** - Production ready -- **Helios Laser System**: ✅ **FULLY IMPLEMENTED** - Production ready -- **Genesis Microscope**: Stub implementation for development -- **T3R-SL Device**: Stub implementation for development - -#### ThorLabs MLS Stage (BBD203 Motor Controller) -- **File**: `hardware/thorlabs_stage.py` -- **Purpose**: 3-axis positioning control via BBD203 controller -- **Connection**: USB with serial number auto-detection -- **Status**: Full implementation - ready for real hardware -- **Protocol**: APT binary protocol v42.1 -- **Usage**: Enter BBD203 serial number in UI, driver auto-finds USB port - -#### Helios Laser System -- **Files**: `hardware/helios_driver.py`, `hardware/helios_protocol.py` -- **Purpose**: Laser control with frequency, current, and pulse mode settings -- **Connection**: RS-232 serial (9600 baud, 8N1) -- **Status**: Full implementation - ready for real hardware -- **Protocol**: ASCII-based RS-232 protocol -- **Features**: - - Frequency control (16.7-125 kHz) - - Current control (0-7000 mA) - - Pulse mode control (single, gating, continuous) - - Temperature monitoring (4 sensors) - - Power monitoring - - Status register with error detection -- **Usage**: Configure via Helios Settings dialog, COM port selected from dropdown - -#### Genesis Microscope -- **File**: `hardware/microscope.py` (Genesis methods) -- **Purpose**: Laser scanning microscope system -- **Connection**: USB/Serial (not implemented) -- **Status**: Stub - simulates microscope connection and status - -#### T3R-SL Device -- **File**: `hardware/t3r_device.py` -- **Purpose**: Timing and trigger control -- **Connection**: USB/Serial (COM port) -- **Status**: Stub - simulates device connection and status - -### Implementing Real Hardware Support - -To add actual hardware support, modify the stub methods in the respective hardware files: - -1. Add real serial communication using `pyserial` -2. Implement manufacturer-specific protocols -3. Add error handling and timeout logic -4. Implement actual status polling from devices - -## UI Event Connections - -All UI elements are connected to handler methods: - -### Buttons -- ThorLabs stage connect/disconnect -- COM port refresh and connect -- Genesis/Helios settings dialogs -- Begin scanning -- Advanced oscilloscope settings - -### ComboBoxes -- COM port selection -- Number of scans -- Row spacing -- Oscilloscope channel selections - -### Text Inputs -- Stage serial number -- Scan coordinates (X/Y start, delta) -- Trigger voltage -- VISA address - -## Development Notes - -### Adding New Hardware -1. Create new controller class in `hardware/` directory -2. Import and instantiate in `main_window.py` -3. Add status update methods -4. Connect to UI elements as needed - -### Modifying UI -1. Edit `.ui` files with Qt Designer -2. UI elements are accessed by their object names -3. Connections are made in `_connect_signals()` method - -### Debug Output -All stub methods print debug information to console. Look for: -- `DEBUG:` - Function calls and state changes -- `INFO:` - Successful operations -- `WARNING:` - Potential issues -- `ERROR:` - Operation failures - -## Testing - -The application can be run without any hardware connected. All hardware interfaces will simulate proper responses. - -### Test Progress Dialog -To test the scan progress dialog with simulated progress: -1. Configure scan parameters -2. Click "Begin Scan" -3. The dialog will show with demo progress animation - -## License - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 diff --git a/nuescan/__main__.py b/nuescan/__main__.py deleted file mode 100644 index 043fd57..0000000 --- a/nuescan/__main__.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -""" -nueScan - SRAS Scan Planning and Control Software -Entry point for the application - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import sys -from PyQt6.QtWidgets import QApplication -from main_window import NueScanMainWindow - - -def main(): - """Main entry point for nueScan application""" - app = QApplication(sys.argv) - app.setApplicationName("nueScan") - app.setOrganizationName("SRAS") - app.setApplicationVersion("0.1.0") - - # Create and show main window - main_window = NueScanMainWindow() - main_window.show() - - sys.exit(app.exec()) - - -if __name__ == "__main__": - main() diff --git a/nuescan/dialogs/__init__.py b/nuescan/dialogs/__init__.py deleted file mode 100644 index 915c255..0000000 --- a/nuescan/dialogs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Dialog controllers for nueScan application -""" diff --git a/nuescan/dialogs/genesis_dialog.py b/nuescan/dialogs/genesis_dialog.py deleted file mode 100644 index 42c2490..0000000 --- a/nuescan/dialogs/genesis_dialog.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Genesis Laser Settings Dialog -Configures Genesis scanning laser parameters -""" - -import os -from PyQt6 import uic -from PyQt6.QtWidgets import QDialog - - -class GenesisDialog(QDialog): - """Dialog for configuring Genesis laser settings""" - - def __init__(self, parent=None): - super().__init__(parent) - - # Load UI file - ui_path = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - 'nuescan_genesis_dialog.ui' - ) - uic.loadUi(ui_path, self) - - self.setWindowTitle("Genesis Laser Settings") - - # Initialize with default values - self._load_default_settings() - - # Connect signals - self._connect_signals() - - def _connect_signals(self): - """Connect dialog signals""" - # LineEdit text changed - self.le_genesis_power_mw.textChanged.connect(self.on_power_changed) - - # Dialog buttons are auto-connected by Qt Designer - - def _load_default_settings(self): - """Load default Genesis settings""" - self.le_genesis_power_mw.setText("100.0") # Default 100mW - - def on_power_changed(self, text): - """Handle scanning power change""" - print(f"DEBUG: Genesis power changed to: {text}") - - def get_settings(self): - """ - Get current Genesis settings as a dictionary - - Returns: - dict: Genesis laser settings - """ - try: - power_mw = float(self.le_genesis_power_mw.text()) - except ValueError: - power_mw = 0.0 - - return { - 'power_mw': power_mw - } - - def set_settings(self, settings): - """ - Set Genesis settings from a dictionary - - Args: - settings (dict): Genesis laser settings - """ - if 'power_mw' in settings: - self.le_genesis_power_mw.setText(str(settings['power_mw'])) diff --git a/nuescan/dialogs/helios_dialog.py b/nuescan/dialogs/helios_dialog.py deleted file mode 100644 index 20641ca..0000000 --- a/nuescan/dialogs/helios_dialog.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Helios Device Settings Dialog -Configures Helios laser parameters and COM port - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import os -from PyQt6 import uic -from PyQt6.QtWidgets import QDialog, QMessageBox -from hardware.helios_driver import HeliosDriver - - -class HeliosDialog(QDialog): - """Dialog for configuring Helios device settings""" - - def __init__(self, parent=None): - super().__init__(parent) - - # Load UI file - ui_path = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - 'nuescan_helios_dialog.ui' - ) - uic.loadUi(ui_path, self) - - self.setWindowTitle("Helios Device Settings") - - # Initialize with default values - self._load_default_settings() - - # Connect signals - self._connect_signals() - - # Populate COM ports - self._populate_com_ports() - - def _connect_signals(self): - """Connect dialog signals""" - # ComboBox value changed - self.cb_helios_port.currentIndexChanged.connect(self.on_port_changed) - - # LineEdit text changed - self.le_helios_frequency.textChanged.connect(self.on_frequency_changed) - self.le_helios_current.textChanged.connect(self.on_current_changed) - - # Dialog buttons are auto-connected by Qt Designer - - def _load_default_settings(self): - """Load default Helios settings""" - self.le_helios_frequency.setText("10000") # Default 10kHz - self.le_helios_current.setText("500") # Default 500mA - - def _populate_com_ports(self): - """Populate available COM ports""" - # Get available ports from system - ports = HeliosDriver.list_available_ports() - - if ports: - self.cb_helios_port.addItems(ports) - print(f"DEBUG: Found {len(ports)} available COM ports") - else: - # No ports found - self.cb_helios_port.addItem("No ports found") - print("WARNING: No COM ports found") - - def refresh_com_ports(self): - """Refresh the COM port list""" - current_port = self.cb_helios_port.currentText() - self.cb_helios_port.clear() - self._populate_com_ports() - - # Try to restore previous selection - index = self.cb_helios_port.findText(current_port) - if index >= 0: - self.cb_helios_port.setCurrentIndex(index) - - def on_port_changed(self, index): - """Handle COM port selection change""" - port = self.cb_helios_port.currentText() - print(f"DEBUG: Helios port changed to: {port}") - - def on_frequency_changed(self, text): - """Handle frequency change""" - print(f"DEBUG: Helios frequency changed to: {text}") - - def on_current_changed(self, text): - """Handle current change""" - print(f"DEBUG: Helios current changed to: {text}") - - def get_settings(self): - """ - Get current Helios settings as a dictionary - - Validates input ranges before returning. - - Returns: - dict: Helios device settings, or None if validation fails - """ - # Validate frequency - try: - frequency_hz = float(self.le_helios_frequency.text()) - # Convert to period to check valid range (8000-60000 ns) - # Valid frequencies: ~16.7 kHz to 125 kHz - if frequency_hz < 16666 or frequency_hz > 125000: - QMessageBox.warning( - self, "Invalid Frequency", - f"Frequency must be between 16.7 kHz and 125 kHz\n" - f"(Period: 8000-60000 ns)\n\n" - f"Entered: {frequency_hz/1000:.1f} kHz" - ) - return None - except ValueError: - QMessageBox.warning( - self, "Invalid Frequency", - "Please enter a valid frequency value in Hz" - ) - return None - - # Validate current - try: - current_ma = float(self.le_helios_current.text()) - if current_ma < 0 or current_ma > 7000: - QMessageBox.warning( - self, "Invalid Current", - f"Current must be between 0 and 7000 mA\n\n" - f"Entered: {current_ma} mA" - ) - return None - except ValueError: - QMessageBox.warning( - self, "Invalid Current", - "Please enter a valid current value in mA" - ) - return None - - # Validate COM port selection - com_port = self.cb_helios_port.currentText() - if not com_port or com_port == "No ports found": - QMessageBox.warning( - self, "No Port Selected", - "Please select a valid COM port" - ) - return None - - return { - 'com_port': com_port, - 'frequency_hz': frequency_hz, - 'current_ma': current_ma - } - - def set_settings(self, settings): - """ - Set Helios settings from a dictionary - - Args: - settings (dict): Helios device settings - """ - if 'com_port' in settings: - index = self.cb_helios_port.findText(settings['com_port']) - if index >= 0: - self.cb_helios_port.setCurrentIndex(index) - - if 'frequency_hz' in settings: - self.le_helios_frequency.setText(str(settings['frequency_hz'])) - - if 'current_ma' in settings: - self.le_helios_current.setText(str(settings['current_ma'])) diff --git a/nuescan/dialogs/oscope_dialog.py b/nuescan/dialogs/oscope_dialog.py deleted file mode 100644 index 34c319c..0000000 --- a/nuescan/dialogs/oscope_dialog.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -nueScan - Oscilloscope Dialog Controller -Handles all UI interactions for the oscilloscope dialog - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import os -from PyQt6 import uic -from PyQt6.QtWidgets import QDialog, QMessageBox - - -class OscopeDialog(QDialog): - """Oscope dialog for nueScan application""" - - def __init__(self, parent, microscope): - super().__init__(parent) - - # Store hardware controllers - self.microscope = microscope - - # Load UI file - ui_path = os.path.join(os.path.dirname(__file__), '..', 'nuescan_oscope_dialog.ui') - uic.loadUi(ui_path, self) - - # Set window title - self.setWindowTitle("nueScan - Oscilloscope Settings") - - # Connect all UI signals - self._connect_signals() - - # Initialize UI state - self._initialize_ui() - - def _connect_signals(self): - """Connect all UI signals to handler methods""" - # ===== ComboBox Value Changed Handlers ===== - self.cb_set_trig_channel.currentIndexChanged.connect(self.on_trigger_channel_changed) - self.cb_set_saw_channel.currentIndexChanged.connect(self.on_saw_channel_changed) - self.cb_set_bias_a_ch.currentIndexChanged.connect(self.on_bias_a_channel_changed) - self.cb_set_bias_b_ch.currentIndexChanged.connect(self.on_bias_b_channel_changed) - - # ===== LineEdit Text Changed Handlers ===== - self.le_set_trigger_voltage.textChanged.connect(self.on_trigger_voltage_changed) - self.le_set_sample_thresh_voltage.textChanged.connect(self.on_sample_thresh_voltage_changed) - self.le_set_pd_trig_voltage.textChanged.connect(self.on_pd_trig_voltage_changed) - self.le_oscope_visa_address.textChanged.connect(self.on_oscope_visa_address_changed) - - - # ===== Button Click Handlers ===== - self.btn_test_scope_connection.clicked.connect(self.on_test_scope_connection_clicked) - self.btn_save_scope_settings.clicked.connect(self.on_save_scope_settings_clicked) - self.btn_cancel_scope_settings.clicked.connect(self.on_cancel_scope_settings_clicked) - - def _initialize_ui(self): - """Initialize UI with default values""" - # Populate combo boxes with dummy data - self._populate_combo_boxes() - - def _populate_combo_boxes(self): - """Populate all combo boxes with initial values""" - # Oscilloscope channels - channels = ["CH1", "CH2", "CH3", "CH4"] - self.cb_set_trig_channel.addItems(channels) - self.cb_set_saw_channel.addItems(channels) - self.cb_set_bias_a_ch.addItems(channels) - self.cb_set_bias_b_ch.addItems(channels) - - - # ==================== ComboBox Change Handlers ==================== - - def on_trigger_channel_changed(self, index): - """Handle Phototrigger channel change""" - channel = self.cb_set_trig_channel.currentText() - print(f"DEBUG: Phototrigger channel changed to: {channel}") - - def on_bias_a_channel_changed(self, index): - """Handle Bias A channel change""" - channel = self.cb_set_bias_a_ch.currentText() - print(f"DEBUG: Bias A channel changed to: {channel}") - - def on_bias_b_channel_changed(self, index): - """Handle Bias B channel change""" - channel = self.cb_set_bias_b_ch.currentText() - print(f"DEBUG: Bias B channel changed to: {channel}") - - def on_saw_channel_changed(self, index): - """Handle RF/SAW channel change""" - channel = self.cb_set_saw_channel.currentText() - print(f"DEBUG: RF/SAW channel changed to: {channel}") - - - # ==================== LineEdit Text Change Handlers ==================== - - def on_pd_trig_voltage_changed(self, text): - """Handle PD Trigger voltage change""" - print(f"DEBUG: PD Trigger voltage changed to: {text}") - - def on_trigger_voltage_changed(self, text): - """Handle Sample Min Bias voltage change""" - print(f"DEBUG: Sample Min Bias voltage changed to: {text}") - - def on_sample_thresh_voltage_changed(self, text): - """Handle Sample Min Bias voltage change (placeholder)""" - print(f"DEBUG: Sample threshold voltage changed to: {text}") - - def on_oscope_visa_address_changed(self, text): - """Handle oscilloscope VISA address change""" - print(f"DEBUG: Oscilloscope VISA address changed to: {text}") - - # ==================== Button Click Handlers ==================== - - def on_test_scope_connection_clicked(self): - """Test the oscilloscope connection""" - print("DEBUG: Test oscope connection clicked") - - def on_save_scope_settings_clicked(self): - """Save the oscilloscope settings""" - print("DEBUG: Save oscope settings clicked") - self.accept() - - def on_cancel_scope_settings_clicked(self): - """Cancel the oscilloscope settings changes""" - print("DEBUG: Cancel oscope settings clicked") - self.reject() \ No newline at end of file diff --git a/nuescan/dialogs/scan_active_dialog.py b/nuescan/dialogs/scan_active_dialog.py deleted file mode 100644 index 40ee461..0000000 --- a/nuescan/dialogs/scan_active_dialog.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -Scan Active Dialog -Displays real-time scanning progress and status -""" - -import os -from PyQt6 import uic -from PyQt6.QtWidgets import QDialog -from PyQt6.QtCore import QTimer - - -class ScanActiveDialog(QDialog): - """Dialog for displaying active scan progress""" - - def __init__(self, parent=None): - super().__init__(parent) - - # Load UI file - ui_path = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - 'nuescan_scan_active_dialog.ui' - ) - uic.loadUi(ui_path, self) - - self.setWindowTitle("Scan in Progress") - - # Make dialog modal - self.setModal(True) - - # Initialize state - self.scan_cancelled = False - - # Connect signals - self._connect_signals() - - # Initialize progress - self._initialize_progress() - - # Demo timer (for testing progress updates) - self._demo_timer = QTimer() - self._demo_timer.timeout.connect(self._demo_update) - self._demo_progress = 0 - - def _connect_signals(self): - """Connect dialog signals""" - self.pb_cancel_scan.clicked.connect(self.on_cancel_clicked) - - def _initialize_progress(self): - """Initialize progress bars and status""" - self.pbar_total_scan.setValue(0) - self.pbar_this_scan.setValue(0) - self.l_status_current_scan.setText("1") - self.l_status_total_scans.setText("1") - self.l_status_current_row.setText("0") - self.l_status_total_rows.setText("0") - self.l_est_time_done.setText("Calculating...") - - def on_cancel_clicked(self): - """Handle cancel button click""" - print("DEBUG: Scan cancelled by user") - self.scan_cancelled = True - self.reject() - - # ==================== Progress Update Methods ==================== - - def update_total_progress(self, current, total): - """ - Update the total scan progress bar - - Args: - current (int): Current scan number - total (int): Total number of scans - """ - if total > 0: - percentage = int((current / total) * 100) - self.pbar_total_scan.setValue(percentage) - - def update_current_scan_progress(self, current, total): - """ - Update the current scan progress bar - - Args: - current (int): Current row number - total (int): Total number of rows - """ - if total > 0: - percentage = int((current / total) * 100) - self.pbar_this_scan.setValue(percentage) - - def update_status(self, scan_num, total_scans, row_num, total_rows, time_remaining): - """ - Update scan status information - - Args: - scan_num (int): Current scan number - total_scans (int): Total number of scans - row_num (int): Current row number - total_rows (int): Total number of rows - time_remaining (str): Estimated time remaining (formatted string) - """ - self.l_status_current_scan.setText(str(scan_num)) - self.l_status_total_scans.setText(str(total_scans)) - self.l_status_current_row.setText(str(row_num)) - self.l_status_total_rows.setText(str(total_rows)) - self.l_est_time_done.setText(f"{time_remaining} remaining...") - - def start_demo_progress(self): - """ - Start a demo progress animation (for testing) - Remove this method in production - """ - self._demo_progress = 0 - self._demo_timer.start(100) # Update every 100ms - - def _demo_update(self): - """ - Demo progress update (for testing) - Remove this method in production - """ - self._demo_progress += 1 - - # Simulate scan progress - total_scans = 5 - rows_per_scan = 100 - total_steps = total_scans * rows_per_scan - - current_scan = (self._demo_progress // rows_per_scan) + 1 - current_row = (self._demo_progress % rows_per_scan) - - if current_scan > total_scans: - self._demo_timer.stop() - self.accept() - return - - # Update progress - self.update_total_progress(current_scan - 1, total_scans) - self.update_current_scan_progress(current_row, rows_per_scan) - - # Calculate time remaining (demo) - remaining_steps = total_steps - self._demo_progress - seconds_remaining = remaining_steps * 0.1 # 0.1s per step - hours = int(seconds_remaining // 3600) - minutes = int((seconds_remaining % 3600) // 60) - seconds = int(seconds_remaining % 60) - - time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}" - - self.update_status( - current_scan, - total_scans, - current_row, - rows_per_scan, - time_str - ) - - def is_cancelled(self): - """ - Check if scan was cancelled - - Returns: - bool: True if cancelled, False otherwise - """ - return self.scan_cancelled diff --git a/nuescan/dialogs/status_dialog.py b/nuescan/dialogs/status_dialog.py deleted file mode 100644 index 4f6ae9c..0000000 --- a/nuescan/dialogs/status_dialog.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -nueScan - Status Dialog Controller -Handles all UI interactions for the status dialog - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import os -from PyQt6 import uic -from PyQt6.QtWidgets import QDialog - - -class StatusDialog(QDialog): - """Status dialog for nueScan application""" - - def __init__(self, parent, thorlabs_stage, t3r_device, microscope): - super().__init__(parent) - - # Store hardware controllers - self.thorlabs_stage = thorlabs_stage - self.t3r_device = t3r_device - self.microscope = microscope - - # Load UI file - ui_path = os.path.join(os.path.dirname(__file__), '..', 'nuescan_status_dialog.ui') - uic.loadUi(ui_path, self) - - # Set window title - self.setWindowTitle("nueScan - Status Indicators") - - def update_all_status(self): - """Update all status labels with current hardware states""" - self._update_stage_status() - self._update_t3r_status() - self._update_microscope_status() - self._update_transfer_system_status() - - def _update_stage_status(self): - """Update ThorLabs stage status indicators""" - status = self.thorlabs_stage.get_status() - - self.l_is_mls_connected.setText("Yes" if status['connected'] else "No") - self.l_is_mls_x_home.setText("Yes" if status['x_homed'] else "No") - self.l_is_mls_y_home.setText("Yes" if status['y_homed'] else "No") - self.l_is_mls_ready.setText("Yes" if status['ready'] else "No") - self.l_is_mls_scanning.setText("Yes" if status['scanning'] else "No") - - def _update_t3r_status(self): - """Update T3R device status indicators""" - status = self.t3r_device.get_status() - - self.l_is_t3r_connected.setText("Yes" if status['connected'] else "No") - self.l_is_t3r_homed.setText("Yes" if status['homed'] else "No") - self.l_is_t3r_ready.setText("Yes" if status['ready'] else "No") - - def _update_microscope_status(self): - """Update microscope (Genesis/Helios) status indicators""" - status = self.microscope.get_status() - - # Helios status - self.l_is_helios_ready.setText("Yes" if status['helios_ready'] else "No") - self.l_is_helios_interlocked.setText("Yes" if status['helios_interlocked'] else "No") - - # Genesis status - self.l_is_genesis_ready.setText("Yes" if status['genesis_ready'] else "No") - self.l_is_genesis_interlocked.setText("Yes" if status['genesis_interlocked'] else "No") - - def _update_transfer_system_status(self): - """Update Robo-met.3D transfer system status indicators""" - # Stub implementation - would read from actual I/O - # These represent digital I/O states - io_states = self._read_transfer_io_states() - - # SRAS outputs - self.l_sras_ok.setText("High (1)" if io_states['sras_ok'] else "Low (0)") - self.l_sras_ctl.setText("High (1)" if io_states['sras_ctl'] else "Low (0)") - self.l_sras_done.setText("High (1)" if io_states['sras_done'] else "Low (0)") - self.l_sras_error.setText("High (1)" if io_states['sras_error'] else "Low (0)") - - # R3D inputs - self.l_r3d_estop_ok.setText("High (1)" if io_states['r3d_estop'] else "Low (0)") - self.l_r3d_rtl.setText("High (1)" if io_states['r3d_ready_to_load'] else "Low (0)") - self.l_r3d_rts.setText("High (1)" if io_states['r3d_ready_to_start'] else "Low (0)") - self.l_r3d_spare.setText("High (1)" if io_states['r3d_spare'] else "Low (0)") - - def _read_transfer_io_states(self): - """ - Stub method to read transfer system I/O states - In production, this would read from actual hardware I/O - """ - return { - 'sras_ok': False, - 'sras_ctl': False, - 'sras_done': False, - 'sras_error': False, - 'r3d_estop': True, # Active low, so True = OK - 'r3d_ready_to_load': False, - 'r3d_ready_to_start': False, - 'r3d_spare': False - } \ No newline at end of file diff --git a/nuescan/hardware/__init__.py b/nuescan/hardware/__init__.py deleted file mode 100644 index f348cdb..0000000 --- a/nuescan/hardware/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Hardware communication modules for nueScan -Handles communication with ThorLabs stage, T3R device, and microscopes -""" diff --git a/nuescan/hardware/bbd203_driver.py b/nuescan/hardware/bbd203_driver.py deleted file mode 100644 index 8b3cbf1..0000000 --- a/nuescan/hardware/bbd203_driver.py +++ /dev/null @@ -1,928 +0,0 @@ -""" -ThorLabs BBD203 3-Channel Motor Controller Driver -Complete implementation of the APT protocol for BBD203 - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import serial -import serial.tools.list_ports -import time -import threading -from typing import Dict, List, Optional, Callable, Tuple -from queue import Queue, Empty - -from hardware.bbd203_protocol import ( - APTProtocol, APTMessage, MessageID, StatusBits, TriggerMode, Destination -) - - -class BBD203Channel: - """Represents a single channel on the BBD203""" - - def __init__(self, channel_num: int): - """ - Initialize channel - - Args: - channel_num: Channel number (1, 2, or 3) - """ - self.channel_num = channel_num - self.enabled = False - self.homed = False - self.position_mm = 0.0 - self.encoder_count = 0 - self.status_bits = 0 - self.moving = False - self.homing = False - self.error = False - - def update_from_status(self, position: int, encoder: int, status: int, - protocol: APTProtocol): - """Update channel state from status update""" - self.position_mm = protocol.apt_to_position(position) - self.encoder_count = encoder - self.status_bits = status - - # Parse status bits - self.homed = bool(status & StatusBits.HOMED) - self.homing = bool(status & StatusBits.HOMING) - self.enabled = bool(status & StatusBits.MOTOR_ENABLED) - self.error = bool(status & StatusBits.MOTION_ERROR) - - # Check if moving - self.moving = bool(status & ( - StatusBits.IN_MOTION_FORWARD | - StatusBits.IN_MOTION_REVERSE | - StatusBits.JOGGING_FORWARD | - StatusBits.JOGGING_REVERSE | - StatusBits.HOMING - )) - - def is_ready(self) -> bool: - """Check if channel is ready for operation""" - return self.enabled and self.homed and not self.error - - -class BBD203Driver: - """ - Complete driver for ThorLabs BBD203 3-Channel Motor Controller - - Features: - - 3 independent motor channels - - Binary APT protocol communication - - Automatic status updates - - Thread-safe operation - - Position and velocity control - """ - - def __init__(self, encoder_counts_per_mm: int = 20000, timeout: float = 1.0): - """ - Initialize BBD203 driver - - Args: - encoder_counts_per_mm: Encoder resolution (default: 20000 for MLS203) - timeout: Serial communication timeout in seconds - """ - self.protocol = APTProtocol(encoder_counts_per_mm) - self.timeout = timeout - - # Serial connection - self._serial: Optional[serial.Serial] = None - self._port_name = "" - self._connected = False - - # Channels - self.channels = { - 1: BBD203Channel(1), - 2: BBD203Channel(2), - 3: BBD203Channel(3) - } - - # Communication thread - self._rx_thread: Optional[threading.Thread] = None - self._stop_thread = threading.Event() - self._rx_queue = Queue() - - # Callbacks for asynchronous events - self._move_complete_callbacks: Dict[int, List[Callable]] = {1: [], 2: [], 3: []} - self._home_complete_callbacks: Dict[int, List[Callable]] = {1: [], 2: [], 3: []} - - # Hardware info - self._hw_info = {} - - # ==================== Connection Management ==================== - - @staticmethod - def list_available_ports() -> List[str]: - """ - List available serial ports - - Returns: - list: Available port names - """ - ports = serial.tools.list_ports.comports() - return [port.device for port in ports] - - @staticmethod - def list_thorlabs_devices() -> List[Dict[str, str]]: - """ - List all ThorLabs APT devices connected via USB - - Returns: - list: List of dictionaries containing device information - Each dict has: 'serial', 'port', 'description', 'vid', 'pid' - """ - thorlabs_devices = [] - ports = serial.tools.list_ports.comports() - - # ThorLabs devices typically use FTDI chips - # Common VID/PID combinations: - # - FTDI: VID=0x0403, various PIDs - thorlabs_vids = [0x0403] # FTDI vendor ID - - for port in ports: - # Check if this is a ThorLabs device by VID - if port.vid in thorlabs_vids: - device_info = { - 'serial': port.serial_number or 'Unknown', - 'port': port.device, - 'description': port.description or 'Unknown', - 'manufacturer': port.manufacturer or 'Unknown', - 'vid': f"0x{port.vid:04X}" if port.vid else 'Unknown', - 'pid': f"0x{port.pid:04X}" if port.pid else 'Unknown' - } - thorlabs_devices.append(device_info) - print(f"DEBUG: Found ThorLabs device - Serial: {device_info['serial']}, " - f"Port: {device_info['port']}") - - return thorlabs_devices - - @staticmethod - def find_device_by_serial(serial_number: str) -> Optional[str]: - """ - Find ThorLabs device by serial number and return its port - - Args: - serial_number: Device serial number (e.g., '83123456') - - Returns: - str: COM port name if found, None otherwise - """ - devices = BBD203Driver.list_thorlabs_devices() - - for device in devices: - if device['serial'] == serial_number: - print(f"INFO: Found device {serial_number} on port {device['port']}") - return device['port'] - - print(f"WARNING: Device with serial number {serial_number} not found") - print(f"Available devices: {[d['serial'] for d in devices]}") - return None - - def connect_by_serial(self, serial_number: str, baudrate: int = 115200) -> bool: - """ - Connect to BBD203 controller by serial number (auto-find port) - - This is the preferred connection method - automatically finds the - device by serial number over USB, similar to Kinesis library. - - Args: - serial_number: Device serial number (e.g., '83123456') - baudrate: Baud rate (default: 115200) - - Returns: - bool: True if connection successful - - Example: - driver.connect_by_serial('83123456') - """ - # Find device port by serial number - port = self.find_device_by_serial(serial_number) - - if port is None: - print(f"ERROR: Could not find BBD203 with serial number {serial_number}") - print("Available ThorLabs devices:") - for device in self.list_thorlabs_devices(): - print(f" Serial: {device['serial']}, Port: {device['port']}, " - f"Description: {device['description']}") - return False - - # Connect using the found port - return self.connect(port, baudrate) - - def connect(self, port: str, baudrate: int = 115200) -> bool: - """ - Connect to BBD203 controller by port name - - Note: It's recommended to use connect_by_serial() instead, which - automatically finds the device by serial number. - - Args: - port: Serial port name (e.g., 'COM3' or '/dev/ttyUSB0') - baudrate: Baud rate (default: 115200) - - Returns: - bool: True if connection successful - """ - try: - print(f"INFO: Connecting to BBD203 on {port}") - - self._serial = serial.Serial( - port=port, - baudrate=baudrate, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=self.timeout, - rtscts=False, # Disable hardware flow control - xonxoff=False # Disable software flow control - ) - - # Set DTR and RTS for ThorLabs FTDI devices - # For BBD203, RTS should be LOW to enable communication - self._serial.dtr = False - self._serial.rts = False - - self._port_name = port - self._connected = True - - # Give controller time to initialize after connection - time.sleep(0.5) - - # Start receive thread - self._stop_thread.clear() - self._rx_thread = threading.Thread(target=self._receive_loop, daemon=True) - self._rx_thread.start() - - # Initialize controller - time.sleep(0.5) # Allow thread to start and controller to be ready - - # Request hardware info - self._send_command(self.protocol.cmd_req_hw_info()) - time.sleep(0.5) - - # Start automatic status updates - self._send_command(self.protocol.cmd_start_update_msgs()) - time.sleep(0.5) - - print(f"INFO: Successfully connected to BBD203 on {port}") - return True - - except serial.SerialException as e: - print(f"ERROR: Failed to connect to {port}: {e}") - self._connected = False - return False - - def disconnect(self) -> bool: - """ - Disconnect from BBD203 controller - - Returns: - bool: True if disconnection successful - """ - if not self._connected: - return True - - try: - print("INFO: Disconnecting from BBD203") - - # Stop status updates - self._send_command(self.protocol.cmd_stop_update_msgs()) - time.sleep(0.1) - - # Stop receive thread - self._stop_thread.set() - if self._rx_thread: - self._rx_thread.join(timeout=2.0) - - # Close serial port - if self._serial and self._serial.is_open: - self._serial.close() - - self._connected = False - print("INFO: Disconnected from BBD203") - return True - - except Exception as e: - print(f"ERROR: Error during disconnect: {e}") - return False - - def is_connected(self) -> bool: - """Check if controller is connected""" - return self._connected and self._serial and self._serial.is_open - - # ==================== Communication Methods ==================== - - def _send_command(self, cmd: bytes) -> bool: - """ - Send command to controller - - Args: - cmd: Command bytes to send - - Returns: - bool: True if send successful - """ - if not self.is_connected(): - print("ERROR: Cannot send command - not connected") - return False - - try: - print(f"DEBUG: Sending {len(cmd)} bytes: {cmd.hex()}") - self._serial.write(cmd) - self._serial.flush() # Ensure data is sent - return True - except serial.SerialException as e: - print(f"ERROR: Failed to send command: {e}") - return False - - def _receive_loop(self): - """Background thread to receive messages from controller""" - buffer = bytearray() - print("DEBUG: Receive thread started") - - while not self._stop_thread.is_set(): - try: - if self._serial.in_waiting > 0: - data = self._serial.read(self._serial.in_waiting) - print(f"DEBUG: Received {len(data)} bytes: {data.hex()}") - buffer.extend(data) - - # Process complete messages - while len(buffer) >= 6: - # Parse header - msg_id, data_len, dest, source = APTMessage.parse_header(buffer) - - # Determine total message length - if data_len == 0 or data_len > 255: - # Header-only message - msg_len = 6 - else: - # Message with data - msg_len = 6 + data_len - - # Wait for complete message - if len(buffer) < msg_len: - break - - # Extract message - msg = bytes(buffer[:msg_len]) - buffer = buffer[msg_len:] - - # Process message - self._process_message(msg_id, msg) - - else: - time.sleep(0.001) # Small delay to prevent busy waiting - - except Exception as e: - if not self._stop_thread.is_set(): - print(f"ERROR: Exception in receive loop: {e}") - time.sleep(0.1) - - def _process_message(self, msg_id: int, msg: bytes): - """ - Process received message - - Args: - msg_id: Message ID - msg: Complete message bytes - """ - try: - print(f"DEBUG: Processing message ID 0x{msg_id:04X}, len={len(msg)}, data={msg.hex()}") - - if msg_id == MessageID.MGMSG_MOT_GET_STATUSUPDATE: - # Status update - channel, position, encoder, status = APTMessage.parse_status_update(msg) - # Determine which channel this is for (from destination byte) - dest = msg[4] - channel_num = dest - 0x20 # 0x21->1, 0x22->2, 0x23->3 - - if channel_num in self.channels: - self.channels[channel_num].update_from_status( - position, encoder, status, self.protocol - ) - - elif msg_id == MessageID.MGMSG_MOT_MOVE_COMPLETED: - # Move completed - dest = msg[4] - channel_num = dest - 0x20 - - if channel_num in self.channels: - self.channels[channel_num].moving = False - - # Call callbacks - for callback in self._move_complete_callbacks.get(channel_num, []): - callback(channel_num) - - elif msg_id == MessageID.MGMSG_MOT_MOVE_HOMED: - # Homing completed - dest = msg[4] - channel_num = dest - 0x20 - - if channel_num in self.channels: - self.channels[channel_num].homed = True - self.channels[channel_num].homing = False - - # Call callbacks - for callback in self._home_complete_callbacks.get(channel_num, []): - callback(channel_num) - - elif msg_id == MessageID.MGMSG_MOT_MOVE_STOPPED: - # Motion stopped - dest = msg[4] - channel_num = dest - 0x20 - - if channel_num in self.channels: - self.channels[channel_num].moving = False - - elif msg_id == MessageID.MGMSG_MOD_GET_CHANENABLESTATE: - # Channel enable state - channel, enabled = APTMessage.parse_channel_enable_state(msg) - print(f"DEBUG: Received CHANENABLESTATE - parsed channel={channel}, enabled={enabled}, msg={msg.hex()}") - - # Use the channel number from the parsed message - if channel in self.channels: - self.channels[channel].enabled = enabled - print(f"DEBUG: Set channel {channel} enabled={enabled}") - - elif msg_id == MessageID.MGMSG_HW_RESPONSE: - # Hardware response (error or acknowledgement) - print(f"DEBUG: Received HW_RESPONSE: {msg.hex()}") - - elif msg_id == MessageID.MGMSG_HW_GET_INFO: - # Hardware info - print(f"DEBUG: Received hardware info") - - except Exception as e: - print(f"ERROR: Failed to process message {msg_id:04X}: {e}") - - def _set_and_verify_enable(self, channel: int, enable: bool, retries: int = 3) -> bool: - """ - Set channel enable state and verify it was set correctly - - Args: - channel: Channel number (1, 2, or 3) - enable: True to enable, False to disable - retries: Number of retry attempts - - Returns: - bool: True if value was set and verified - """ - for attempt in range(retries): - # Send enable command - cmd = self.protocol.cmd_enable_channel(channel, enable) - if not self._send_command(cmd): - continue - - time.sleep(0.5) # Wait for controller to process - - # Request channel enable state to verify - req_cmd = self.protocol.cmd_req_channel_enable_state(channel) - self._send_command(req_cmd) - time.sleep(0.5) # Wait for response - - # Check if state matches expected - if self.channels[channel].enabled == enable: - return True - - if attempt < retries - 1: - print(f"DEBUG: Enable verification failed for channel {channel}, " - f"retrying ({attempt + 1}/{retries})") - time.sleep(0.2) - - print(f"ERROR: Failed to set and verify enable state for channel {channel} " - f"after {retries} attempts") - return False - - # ==================== Channel Control ==================== - - def enable_channel(self, channel: int, enable: bool = True) -> bool: - """ - Enable or disable a motor channel - - Args: - channel: Channel number (1, 2, or 3) - enable: True to enable, False to disable - - Returns: - bool: True if command sent successfully - """ - if channel not in [1, 2, 3]: - print(f"ERROR: Invalid channel number: {channel}") - return False - - action = "Enabling" if enable else "Disabling" - print(f"DEBUG: {action} channel {channel}") - - # Use set and verify to ensure command was processed - return self._set_and_verify_enable(channel, enable) - - def identify(self, channel: int) -> bool: - """ - Flash front panel LEDs to identify controller - - Args: - channel: Channel number (1, 2, or 3) - - Returns: - bool: True if command sent successfully - """ - print(f"DEBUG: Identifying channel {channel}") - cmd = self.protocol.cmd_identify(channel) - return self._send_command(cmd) - - # ==================== Homing ==================== - - def home_channel(self, channel: int, wait: bool = False, timeout: float = 30.0) -> bool: - """ - Home a motor channel - - Args: - channel: Channel number (1, 2, or 3) - wait: If True, block until homing complete - timeout: Timeout in seconds if waiting - - Returns: - bool: True if homing initiated (or completed if wait=True) - """ - if channel not in [1, 2, 3]: - print(f"ERROR: Invalid channel number: {channel}") - return False - - print(f"DEBUG: Homing channel {channel}") - - self.channels[channel].homing = True - self.channels[channel].homed = False - - cmd = self.protocol.cmd_move_home(channel) - if not self._send_command(cmd): - return False - - if wait: - # Wait for homing to complete - start_time = time.time() - while time.time() - start_time < timeout: - if self.channels[channel].homed and not self.channels[channel].homing: - print(f"INFO: Channel {channel} homing completed") - return True - time.sleep(0.1) - - print(f"ERROR: Homing timeout for channel {channel}") - return False - - return True - - def home_all_channels(self, wait: bool = False, timeout: float = 30.0) -> bool: - """ - Home all enabled channels - - Args: - wait: If True, block until all homing complete - timeout: Timeout in seconds if waiting - - Returns: - bool: True if all homing operations successful - """ - success = True - for channel in [1, 2, 3]: - if self.channels[channel].enabled: - if not self.home_channel(channel, wait=False): - success = False - - if wait: - start_time = time.time() - while time.time() - start_time < timeout: - all_homed = all( - ch.homed for ch in self.channels.values() if ch.enabled - ) - if all_homed: - print("INFO: All channels homed successfully") - return True - time.sleep(0.1) - - print("ERROR: Timeout waiting for all channels to home") - return False - - return success - - # ==================== Motion Control ==================== - - def move_absolute(self, channel: int, position_mm: float, - wait: bool = False, timeout: float = 30.0) -> bool: - """ - Move to absolute position - - Args: - channel: Channel number (1, 2, or 3) - position_mm: Target position in mm - wait: If True, block until move complete - timeout: Timeout in seconds if waiting - - Returns: - bool: True if move initiated (or completed if wait=True) - """ - if channel not in [1, 2, 3]: - print(f"ERROR: Invalid channel number: {channel}") - return False - - if not self.channels[channel].is_ready(): - print(f"ERROR: Channel {channel} not ready for movement") - return False - - print(f"DEBUG: Moving channel {channel} to {position_mm} mm") - - self.channels[channel].moving = True - - cmd = self.protocol.cmd_move_absolute(channel, position_mm) - if not self._send_command(cmd): - return False - - if wait: - # Wait for move to complete - start_time = time.time() - while time.time() - start_time < timeout: - if not self.channels[channel].moving: - print(f"INFO: Channel {channel} move completed") - return True - time.sleep(0.01) - - print(f"ERROR: Move timeout for channel {channel}") - return False - - return True - - def move_relative(self, channel: int, distance_mm: float, - wait: bool = False, timeout: float = 30.0) -> bool: - """ - Move relative distance - - Args: - channel: Channel number (1, 2, or 3) - distance_mm: Distance to move in mm (positive or negative) - wait: If True, block until move complete - timeout: Timeout in seconds if waiting - - Returns: - bool: True if move initiated (or completed if wait=True) - """ - if channel not in [1, 2, 3]: - print(f"ERROR: Invalid channel number: {channel}") - return False - - if not self.channels[channel].is_ready(): - print(f"ERROR: Channel {channel} not ready for movement") - return False - - print(f"DEBUG: Moving channel {channel} by {distance_mm} mm") - - self.channels[channel].moving = True - - cmd = self.protocol.cmd_move_relative(channel, distance_mm) - if not self._send_command(cmd): - return False - - if wait: - # Wait for move to complete - start_time = time.time() - while time.time() - start_time < timeout: - if not self.channels[channel].moving: - print(f"INFO: Channel {channel} move completed") - return True - time.sleep(0.01) - - print(f"ERROR: Move timeout for channel {channel}") - return False - - return True - - def stop(self, channel: int, immediate: bool = True) -> bool: - """ - Stop motion - - Args: - channel: Channel number (1, 2, or 3), or 0 for all channels - immediate: If True, stop immediately; if False, decelerate - - Returns: - bool: True if stop command sent successfully - """ - if channel == 0: - # Stop all channels - success = True - for ch in [1, 2, 3]: - if not self.stop(ch, immediate): - success = False - return success - - if channel not in [1, 2, 3]: - print(f"ERROR: Invalid channel number: {channel}") - return False - - print(f"DEBUG: Stopping channel {channel}") - - cmd = self.protocol.cmd_move_stop(channel, immediate) - return self._send_command(cmd) - - # ==================== Parameter Setting ==================== - - def set_velocity_params(self, channel: int, max_vel_mm_s: float, - accel_mm_s2: float) -> bool: - """ - Set velocity and acceleration parameters - - Args: - channel: Channel number (1, 2, or 3) - max_vel_mm_s: Maximum velocity in mm/s - accel_mm_s2: Acceleration in mm/s² - - Returns: - bool: True if parameters set successfully - """ - if channel not in [1, 2, 3]: - print(f"ERROR: Invalid channel number: {channel}") - return False - - print(f"DEBUG: Setting velocity params for channel {channel}: " - f"vel={max_vel_mm_s} mm/s, accel={accel_mm_s2} mm/s²") - - cmd = self.protocol.cmd_set_velocity_params(channel, max_vel_mm_s, accel_mm_s2) - if self._send_command(cmd): - time.sleep(0.1) # Wait for controller to process - - # Request status update to confirm parameters were accepted - self.request_status_update(channel) - time.sleep(0.1) # Wait for status response - return True - return False - - # ==================== Status and Position ==================== - - def get_position(self, channel: int) -> Optional[float]: - """ - Get current position of channel - - Args: - channel: Channel number (1, 2, or 3) - - Returns: - float: Current position in mm, or None if unavailable - """ - if channel not in [1, 2, 3]: - return None - - return self.channels[channel].position_mm - - def get_channel_status(self, channel: int) -> Optional[Dict]: - """ - Get detailed status of channel - - Args: - channel: Channel number (1, 2, or 3) - - Returns: - dict: Channel status dictionary - """ - if channel not in [1, 2, 3]: - return None - - ch = self.channels[channel] - return { - 'channel': channel, - 'enabled': ch.enabled, - 'homed': ch.homed, - 'homing': ch.homing, - 'moving': ch.moving, - 'error': ch.error, - 'ready': ch.is_ready(), - 'position_mm': ch.position_mm, - 'encoder_count': ch.encoder_count, - 'status_bits': ch.status_bits - } - - def request_status_update(self, channel: int) -> bool: - """ - Request immediate status update for channel - - Args: - channel: Channel number (1, 2, or 3) - - Returns: - bool: True if request sent successfully - """ - if channel not in [1, 2, 3]: - return False - - cmd = self.protocol.cmd_req_status_update(channel) - return self._send_command(cmd) - - # ==================== Callbacks ==================== - - def register_move_complete_callback(self, channel: int, callback: Callable): - """Register callback for move complete event""" - if channel in [1, 2, 3]: - self._move_complete_callbacks[channel].append(callback) - - def register_home_complete_callback(self, channel: int, callback: Callable): - """Register callback for home complete event""" - if channel in [1, 2, 3]: - self._home_complete_callbacks[channel].append(callback) - - # ==================== Trigger Configuration ==================== - - def set_trigger_mode(self, channel: int, mode: int, polarity: int = 0x01, - start_pos_fwd: float = 0.0, start_pos_rev: float = 0.0, - interval_fwd: float = 0.0, interval_rev: float = 0.0) -> bool: - """ - Set trigger configuration for a channel - - Args: - channel: Channel number (1, 2, or 3) - mode: Trigger mode (TriggerMode enum value) - polarity: Trigger polarity (0x01 = active high, 0x02 = active low) - start_pos_fwd: Start position for forward trigger (mm) - start_pos_rev: Start position for reverse trigger (mm) - interval_fwd: Interval for forward trigger (mm) - interval_rev: Interval for reverse trigger (mm) - - Returns: - bool: True if trigger configuration set successfully - - Example: - # Disable trigger - driver.set_trigger_mode(1, TriggerMode.DISABLED) - - # Enable trigger output on motion - driver.set_trigger_mode(1, TriggerMode.OUT_ONLY) - - # Trigger at specific positions - driver.set_trigger_mode(1, TriggerMode.OUT_POSITION, - start_pos_fwd=10.0, interval_fwd=1.0) - """ - if channel not in [1, 2, 3]: - print(f"ERROR: Invalid channel number: {channel}") - return False - - print(f"DEBUG: Setting trigger mode for channel {channel}: mode={mode}") - - cmd = self.protocol.cmd_set_trigger( - channel, mode, polarity, start_pos_fwd, start_pos_rev, - interval_fwd, interval_rev - ) - - if self._send_command(cmd): - time.sleep(0.1) # Wait for controller to process - return True - return False - - def get_trigger_config(self, channel: int) -> Optional[Dict]: - """ - Get current trigger configuration for a channel - - Args: - channel: Channel number (1, 2, or 3) - - Returns: - dict: Trigger configuration or None if unavailable - """ - if channel not in [1, 2, 3]: - return None - - cmd = self.protocol.cmd_req_trigger(channel) - if not self._send_command(cmd): - return None - - # Note: In a complete implementation, would wait for response - # For now, returning None as response handling would need queue - print("WARNING: get_trigger_config not fully implemented (requires response queue)") - return None - - # ==================== Digital I/O ==================== - - def set_digital_outputs(self, output_bits: int) -> bool: - """ - Set digital output states - - Args: - output_bits: Bit pattern for outputs (0x00 to 0xFF) - - Returns: - bool: True if digital outputs set successfully - - Note: - Digital outputs share pins with trigger outputs. Ensure - trigger mode is disabled before using digital outputs. - """ - if not (0 <= output_bits <= 0xFF): - print(f"ERROR: Invalid output bits: {output_bits}") - return False - - print(f"DEBUG: Setting digital outputs: 0x{output_bits:02X}") - - cmd = self.protocol.cmd_set_digital_outputs(output_bits) - if self._send_command(cmd): - time.sleep(0.05) - return True - return False diff --git a/nuescan/hardware/bbd203_protocol.py b/nuescan/hardware/bbd203_protocol.py deleted file mode 100644 index beb5505..0000000 --- a/nuescan/hardware/bbd203_protocol.py +++ /dev/null @@ -1,532 +0,0 @@ -""" -ThorLabs BBD203 APT Protocol Handler -Binary message protocol for BBD203 motor controller - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import struct -from enum import IntEnum -from typing import Tuple, Optional, List - - -# Message IDs -class MessageID(IntEnum): - """APT Protocol Message IDs for BBD203""" - - # Module Control - MGMSG_MOD_IDENTIFY = 0x0223 - MGMSG_MOD_SET_CHANENABLESTATE = 0x0210 - MGMSG_MOD_REQ_CHANENABLESTATE = 0x0211 - MGMSG_MOD_GET_CHANENABLESTATE = 0x0212 - - # Hardware Control - MGMSG_HW_DISCONNECT = 0x0002 - MGMSG_HW_RESPONSE = 0x0080 - MGMSG_HW_RICHRESPONSE = 0x0081 - MGMSG_HW_START_UPDATEMSGS = 0x0011 - MGMSG_HW_STOP_UPDATEMSGS = 0x0012 - MGMSG_HW_REQ_INFO = 0x0005 - MGMSG_HW_GET_INFO = 0x0006 - - # Motor Control - Basic - MGMSG_MOT_SET_POSCOUNTER = 0x0410 - MGMSG_MOT_REQ_POSCOUNTER = 0x0411 - MGMSG_MOT_GET_POSCOUNTER = 0x0412 - MGMSG_MOT_SET_ENCCOUNTER = 0x0409 - MGMSG_MOT_REQ_ENCCOUNTER = 0x040A - MGMSG_MOT_GET_ENCCOUNTER = 0x040B - - # Motor Control - Homing - MGMSG_MOT_SET_HOMEPARAMS = 0x0440 - MGMSG_MOT_REQ_HOMEPARAMS = 0x0441 - MGMSG_MOT_GET_HOMEPARAMS = 0x0442 - MGMSG_MOT_MOVE_HOME = 0x0443 - MGMSG_MOT_MOVE_HOMED = 0x0444 - - # Motor Control - Movement - MGMSG_MOT_SET_MOVERELPARAMS = 0x0445 - MGMSG_MOT_REQ_MOVERELPARAMS = 0x0446 - MGMSG_MOT_GET_MOVERELPARAMS = 0x0447 - MGMSG_MOT_MOVE_RELATIVE = 0x0448 - MGMSG_MOT_SET_MOVEABSPARAMS = 0x0450 - MGMSG_MOT_REQ_MOVEABSPARAMS = 0x0451 - MGMSG_MOT_GET_MOVEABSPARAMS = 0x0452 - MGMSG_MOT_MOVE_ABSOLUTE = 0x0453 - MGMSG_MOT_MOVE_COMPLETED = 0x0464 - MGMSG_MOT_MOVE_VELOCITY = 0x0457 - MGMSG_MOT_MOVE_STOP = 0x0465 - MGMSG_MOT_MOVE_STOPPED = 0x0466 - - # Motor Control - Velocity - MGMSG_MOT_SET_VELPARAMS = 0x0413 - MGMSG_MOT_REQ_VELPARAMS = 0x0414 - MGMSG_MOT_GET_VELPARAMS = 0x0415 - - # Motor Control - Status - MGMSG_MOT_REQ_STATUSUPDATE = 0x0480 - MGMSG_MOT_GET_STATUSUPDATE = 0x0481 - MGMSG_MOT_REQ_STATUSBITS = 0x0429 - MGMSG_MOT_GET_STATUSBITS = 0x042A - - # Digital I/O and Trigger - MGMSG_RACK_SET_DIGOUTPUTS = 0x0228 - MGMSG_RACK_REQ_DIGOUTPUTS = 0x0229 - MGMSG_RACK_GET_DIGOUTPUTS = 0x0230 - MGMSG_MOT_SET_TRIGGER = 0x0500 - MGMSG_MOT_REQ_TRIGGER = 0x0501 - MGMSG_MOT_GET_TRIGGER = 0x0502 - - -# Destination addresses -class Destination(IntEnum): - """BBD203 Destination addresses""" - USB = 0x50 - ALL_CHANNELS = 0x11 - CHANNEL_1 = 0x21 - CHANNEL_2 = 0x22 - CHANNEL_3 = 0x23 - - -# Source addresses -class Source(IntEnum): - """Source addresses""" - HOST = 0x01 - - -# Status bits -class StatusBits(IntEnum): - """Motor status bit definitions""" - HOMING = 0x00000200 - HOMED = 0x00000400 - TRACKING = 0x00001000 - SETTLED = 0x00002000 - MOTION_ERROR = 0x00004000 - MOTOR_ENABLED = 0x80000000 - FORWARD_LIMIT = 0x00000001 - REVERSE_LIMIT = 0x00000002 - IN_MOTION_FORWARD = 0x00000010 - IN_MOTION_REVERSE = 0x00000020 - JOGGING_FORWARD = 0x00000040 - JOGGING_REVERSE = 0x00000080 - - -# Trigger modes -class TriggerMode(IntEnum): - """Trigger mode definitions""" - DISABLED = 0x00 - IN_OUT_RELATIVE_MOVE = 0x01 - IN_OUT_ABSOLUTE_MOVE = 0x02 - IN_OUT_HOME = 0x03 - IN_OUT_STOP = 0x04 - OUT_ONLY = 0x10 - OUT_POSITION = 0x11 - - -class APTMessage: - """ - APT Protocol Message Builder and Parser - Handles construction and parsing of binary APT messages - """ - - @staticmethod - def build_header_only(msg_id: int, param1: int, param2: int, - dest: int, source: int = Source.HOST) -> bytes: - """ - Build a 6-byte header-only message - - Args: - msg_id: Message ID (16-bit) - param1: Parameter 1 (8-bit) - param2: Parameter 2 (8-bit) - dest: Destination address - source: Source address (default: HOST) - - Returns: - bytes: 6-byte message - """ - return struct.pack(' 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(' 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(' Tuple[int, int]: - """Parse MGMSG_MOT_GET_POSCOUNTER response""" - if len(data) < 12: - raise ValueError("Insufficient data for position counter") - - _, channel, position = struct.unpack(' Tuple[int, int]: - """Parse MGMSG_MOT_GET_ENCCOUNTER response""" - if len(data) < 12: - raise ValueError("Insufficient data for encoder counter") - - _, channel, encoder = struct.unpack(' 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(' 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(' 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(' 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('= 40: - num_pulses, pulse_width, num_cycles = struct.unpack(' 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(' 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(' bytes: - """Build move relative command""" - dest = Destination.CHANNEL_1 + (channel - 1) - dist_apt = self.position_to_apt(distance_mm) - data = struct.pack(' 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(' 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(' 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(' 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 - ) diff --git a/nuescan/hardware/helios_driver.py b/nuescan/hardware/helios_driver.py deleted file mode 100644 index 7cdb3fa..0000000 --- a/nuescan/hardware/helios_driver.py +++ /dev/null @@ -1,627 +0,0 @@ -""" -Helios Laser Driver -Complete RS-232 driver for Helios laser systems - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import serial -import serial.tools.list_ports -import time -import threading -from typing import Dict, List, Optional, Tuple - -from hardware.helios_protocol import ( - HeliosProtocol, HeliosCommand, HeliosStatus, PulseMode -) - - -class HeliosDriver: - """ - Complete driver for Helios laser system - - Features: - - RS-232 communication at 9600 baud - - All protocol commands supported - - Thread-safe operation - - Temperature monitoring - - Status monitoring - - Power monitoring - """ - - # RS-232 Settings (from protocol document) - BAUDRATE = 9600 - DATABITS = 8 - PARITY = 'N' - STOPBITS = 1 - - def __init__(self, timeout: float = 2.0): - """ - Initialize Helios driver - - Args: - timeout: Serial communication timeout in seconds - """ - self.protocol = HeliosProtocol() - self.timeout = timeout - - # Serial connection - self._serial: Optional[serial.Serial] = None - self._port_name = "" - self._connected = False - - # Communication lock for thread safety - self._comm_lock = threading.Lock() - - # Cached state - self._laser_enabled = False - self._pulse_mode = PulseMode.CONTINUOUS_PULSING - self._frequency_hz = 10000.0 - self._current_ma = 500.0 - self._controller_serial = "" - self._head_serial = "" - - # Temperature monitoring (in °C) - self._pump_temp_c = 0.0 - self._resonator_temp_c = 0.0 - self._qswitch_temp_c = 0.0 - self._power_stage_temp_c = 0.0 - - # Status - self._status = HeliosStatus(0) - self._operation_hours = 0.0 - self._power_mw = 0.0 - - print("INFO: Helios Laser driver initialized") - - # ==================== Connection Management ==================== - - @staticmethod - def list_available_ports() -> List[str]: - """ - List available serial ports - - Returns: - list: Available port names - """ - ports = serial.tools.list_ports.comports() - return [port.device for port in ports] - - def connect(self, port: str) -> bool: - """ - Connect to Helios laser - - Args: - port: Serial port name (e.g., 'COM3', '/dev/ttyUSB0') - - Returns: - bool: True if connection successful - """ - try: - print(f"INFO: Connecting to Helios laser on {port}") - - self._serial = serial.Serial( - port=port, - baudrate=self.BAUDRATE, - bytesize=self.DATABITS, - parity=self.PARITY, - stopbits=self.STOPBITS, - timeout=self.timeout - ) - - self._port_name = port - self._connected = True - - # Read serial numbers - time.sleep(0.5) - self._controller_serial = self.query_controller_serial() - time.sleep(0.5) - self._head_serial = self.query_head_serial() - time.sleep(0.5) - - # Read initial state - self._update_cached_state() - - print(f"INFO: Connected to Helios laser on {port}") - print(f" Controller S/N: {self._controller_serial}") - print(f" Head S/N: {self._head_serial}") - return True - - except serial.SerialException as e: - print(f"ERROR: Failed to connect to {port}: {e}") - self._connected = False - return False - - def disconnect(self) -> bool: - """ - Disconnect from Helios laser - - Returns: - bool: True if disconnection successful - """ - if not self._connected: - return True - - try: - print("INFO: Disconnecting from Helios laser") - - # Turn off laser before disconnecting - self.set_laser_enable(False) - time.sleep(0.5) - - # Close serial port - if self._serial and self._serial.is_open: - self._serial.close() - - self._connected = False - print("INFO: Disconnected from Helios laser") - return True - - except Exception as e: - print(f"ERROR: Error during disconnect: {e}") - return False - - def is_connected(self) -> bool: - """Check if laser is connected""" - return self._connected and self._serial and self._serial.is_open - - # ==================== Communication Methods ==================== - - def _send_command(self, command: bytes) -> bool: - """ - Send command to laser (no response expected) - - Args: - command: Command bytes to send - - Returns: - bool: True if send successful - """ - if not self.is_connected(): - print("ERROR: Cannot send command - not connected") - return False - - try: - with self._comm_lock: - self._serial.write(command) - self._serial.flush() - return True - except serial.SerialException as e: - print(f"ERROR: Failed to send command: {e}") - return False - - def _query(self, command: bytes) -> Optional[str]: - """ - Send query and read response - - Args: - command: Query command bytes - - Returns: - str: Response string, or None if error - """ - if not self.is_connected(): - print("ERROR: Cannot query - not connected") - return None - - try: - with self._comm_lock: - # Clear input buffer - self._serial.reset_input_buffer() - - # Send query - self._serial.write(command) - self._serial.flush() - - # Read response (terminated by CR) - response = self._serial.read_until(b'\r') - - if not response: - print("ERROR: No response from laser") - return None - - return HeliosCommand.parse_response(response) - - except serial.SerialException as e: - print(f"ERROR: Query failed: {e}") - return None - - def _set_and_verify(self, set_cmd: bytes, query_cmd: bytes, - expected_value: str, retries: int = 3) -> bool: - """ - Set a value and verify it was set correctly - - Args: - set_cmd: Command to set value - query_cmd: Command to query value - expected_value: Expected response - retries: Number of retry attempts - - Returns: - bool: True if value was set and verified - """ - for attempt in range(retries): - # Send set command - if not self._send_command(set_cmd): - continue - - time.sleep(0.5) # Wait 500ms for laser to process (per documentation) - - # Query to verify - response = self._query(query_cmd) - if response and response == expected_value: - return True - - if attempt < retries - 1: - print(f"DEBUG: Verification failed, retrying ({attempt + 1}/{retries})") - time.sleep(0.5) - - print(f"ERROR: Failed to set and verify value after {retries} attempts") - return False - - # ==================== Laser Control ==================== - - def set_laser_enable(self, enabled: bool) -> bool: - """ - Enable or disable laser - - Args: - enabled: True to enable, False to disable - - Returns: - bool: True if command successful - """ - print(f"DEBUG: {'Enabling' if enabled else 'Disabling'} laser") - - cmd = self.protocol.cmd_set_laser_enable(enabled) - query_cmd = self.protocol.cmd_query_laser_enable() - expected = "1" if enabled else "0" - - if self._set_and_verify(cmd, query_cmd, expected): - self._laser_enabled = enabled - return True - return False - - def is_laser_enabled(self) -> bool: - """Check if laser is currently enabled""" - return self._laser_enabled - - def query_laser_enable(self) -> bool: - """Query laser enable state from hardware""" - cmd = self.protocol.cmd_query_laser_enable() - response = self._query(cmd) - if response: - self._laser_enabled = (response == "1") - return self._laser_enabled - return False - - # ==================== Pulse Mode Control ==================== - - def set_pulse_mode(self, mode: PulseMode) -> bool: - """ - Set pulse mode - - Args: - mode: PulseMode enum value - - Returns: - bool: True if command successful - """ - print(f"DEBUG: Setting pulse mode to {mode.name}") - - cmd = self.protocol.cmd_set_pulse_mode(mode) - query_cmd = self.protocol.cmd_query_pulse_mode() - expected = str(mode.value) - - if self._set_and_verify(cmd, query_cmd, expected): - self._pulse_mode = mode - return True - return False - - def get_pulse_mode(self) -> PulseMode: - """Get current pulse mode""" - return self._pulse_mode - - # ==================== Frequency Control ==================== - - def set_frequency_hz(self, freq_hz: float) -> bool: - """ - Set laser frequency in Hz - - Args: - freq_hz: Frequency in Hz (16.7 kHz to 125 kHz) - - Returns: - bool: True if command successful - """ - print(f"DEBUG: Setting laser frequency to {freq_hz} Hz") - - try: - cmd = self.protocol.cmd_set_frequency_hz(freq_hz) - period_ns = self.protocol.frequency_to_period_ns(freq_hz) - query_cmd = self.protocol.cmd_query_frequency() - expected = str(period_ns) - - if self._set_and_verify(cmd, query_cmd, expected): - self._frequency_hz = freq_hz - return True - except ValueError as e: - print(f"ERROR: {e}") - return False - - def get_frequency_hz(self) -> float: - """Get current frequency in Hz""" - return self._frequency_hz - - def query_frequency_hz(self) -> Optional[float]: - """Query frequency from hardware (returns Hz)""" - cmd = self.protocol.cmd_query_frequency() - response = self._query(cmd) - if response: - try: - period_ns = int(response) - freq_hz = self.protocol.period_ns_to_frequency(period_ns) - self._frequency_hz = freq_hz - return freq_hz - except (ValueError, ZeroDivisionError) as e: - print(f"ERROR: Failed to parse frequency: {e}") - return None - - # ==================== Current Control ==================== - - def set_current_ma(self, current_ma: float) -> bool: - """ - Set laser diode current in mA - - Args: - current_ma: Current in mA (0-7000) - - Returns: - bool: True if command successful - """ - print(f"DEBUG: Setting laser current to {current_ma} mA") - - try: - cmd = self.protocol.cmd_set_current_ma(current_ma) - query_cmd = self.protocol.cmd_query_current() - expected = str(int(current_ma)) - - if self._set_and_verify(cmd, query_cmd, expected): - self._current_ma = current_ma - return True - except ValueError as e: - print(f"ERROR: {e}") - return False - - def get_current_ma(self) -> float: - """Get current setting in mA""" - return self._current_ma - - # ==================== Temperature Monitoring ==================== - - def query_pump_temperature_c(self) -> Optional[float]: - """Query pump diode temperature in °C""" - cmd = self.protocol.cmd_query_pump_temp() - response = self._query(cmd) - if response: - try: - temp_mc = int(response) - temp_c = self.protocol.millicelsius_to_celsius(temp_mc) - self._pump_temp_c = temp_c - return temp_c - except ValueError as e: - print(f"ERROR: Failed to parse temperature: {e}") - return None - - def query_resonator_temperature_c(self) -> Optional[float]: - """Query resonator/SHG temperature in °C""" - cmd = self.protocol.cmd_query_resonator_temp() - response = self._query(cmd) - if response: - try: - temp_mc = int(response) - temp_c = self.protocol.millicelsius_to_celsius(temp_mc) - self._resonator_temp_c = temp_c - return temp_c - except ValueError as e: - print(f"ERROR: Failed to parse temperature: {e}") - return None - - def query_qswitch_temperature_c(self) -> Optional[float]: - """Query q-switch temperature in °C""" - cmd = self.protocol.cmd_query_qswitch_temp() - response = self._query(cmd) - if response: - try: - temp_mc = int(response) - temp_c = self.protocol.millicelsius_to_celsius(temp_mc) - self._qswitch_temp_c = temp_c - return temp_c - except ValueError as e: - print(f"ERROR: Failed to parse temperature: {e}") - return None - - def query_power_stage_temperature_c(self) -> Optional[float]: - """Query controller power stage temperature in °C""" - cmd = self.protocol.cmd_query_power_stage_temp() - response = self._query(cmd) - if response: - try: - temp_mc = int(response) - temp_c = self.protocol.millicelsius_to_celsius(temp_mc) - self._power_stage_temp_c = temp_c - return temp_c - except ValueError as e: - print(f"ERROR: Failed to parse temperature: {e}") - return None - - def query_all_temperatures(self) -> Dict[str, float]: - """ - Query all temperatures - - Returns: - dict: Temperature readings in °C - """ - temps = {} - temps['pump'] = self.query_pump_temperature_c() - time.sleep(0.5) - temps['resonator'] = self.query_resonator_temperature_c() - time.sleep(0.5) - temps['qswitch'] = self.query_qswitch_temperature_c() - time.sleep(0.5) - temps['power_stage'] = self.query_power_stage_temperature_c() - return temps - - # ==================== Status and Monitoring ==================== - - def query_status(self) -> HeliosStatus: - """Query status register""" - cmd = self.protocol.cmd_query_status() - response = self._query(cmd) - if response: - try: - status_value = int(response) - self._status = HeliosStatus(status_value) - return self._status - except ValueError as e: - print(f"ERROR: Failed to parse status: {e}") - return self._status - - def clear_status(self) -> bool: - """Clear status register""" - cmd = self.protocol.cmd_clear_status() - return self._send_command(cmd) - - def clear_errors(self) -> bool: - """Clear controller errors""" - cmd = self.protocol.cmd_clear_errors() - return self._send_command(cmd) - - def query_power_monitor_mw(self) -> Optional[float]: - """Query laser power monitor in mW""" - cmd = self.protocol.cmd_query_power_monitor() - response = self._query(cmd) - if response: - try: - power_mw = float(response) - self._power_mw = power_mw - return power_mw - except ValueError as e: - print(f"ERROR: Failed to parse power: {e}") - return None - - def query_operation_hours(self) -> Optional[float]: - """Query laser diode operation time in hours""" - cmd = self.protocol.cmd_query_operation_time() - response = self._query(cmd) - if response: - try: - hours = float(response) - self._operation_hours = hours - return hours - except ValueError as e: - print(f"ERROR: Failed to parse operation time: {e}") - return None - - # ==================== Serial Numbers ==================== - - def query_controller_serial(self) -> str: - """Query controller serial number""" - cmd = self.protocol.cmd_query_controller_serial() - response = self._query(cmd) - if response: - self._controller_serial = response - return response - return "" - - def query_head_serial(self) -> str: - """Query laser head serial number""" - cmd = self.protocol.cmd_query_head_serial() - response = self._query(cmd) - if response: - self._head_serial = response - return response - return "" - - def get_controller_serial(self) -> str: - """Get cached controller serial number""" - return self._controller_serial - - def get_head_serial(self) -> str: - """Get cached head serial number""" - return self._head_serial - - # ==================== Factory Reset ==================== - - def restore_factory_settings(self) -> bool: - """ - Restore factory settings - - WARNING: This will reset all parameters to factory defaults. - Laser must be disabled before calling this method. - After calling, wait 2 seconds before power cycling. - - Returns: - bool: True if command sent successfully - """ - if self._laser_enabled: - print("ERROR: Laser must be disabled before factory reset") - return False - - print("WARNING: Restoring factory settings") - cmd = self.protocol.cmd_restore_factory() - if self._send_command(cmd): - print("INFO: Factory settings restored. Wait 2s before power cycle.") - time.sleep(2) - return True - return False - - # ==================== State Management ==================== - - def _update_cached_state(self): - """Update all cached state from hardware""" - self.query_laser_enable() - time.sleep(0.5) - self.query_frequency_hz() - time.sleep(0.5) - # Current is write-only in some modes, skip query - self.query_status() - - def get_status(self) -> Dict: - """ - Get complete laser status - - Returns: - dict: Comprehensive status dictionary - """ - return { - 'connected': self.is_connected(), - 'laser_enabled': self._laser_enabled, - 'pulse_mode': self._pulse_mode.name, - 'frequency_hz': self._frequency_hz, - 'current_ma': self._current_ma, - 'power_mw': self._power_mw, - 'operation_hours': self._operation_hours, - 'temperatures': { - 'pump_c': self._pump_temp_c, - 'resonator_c': self._resonator_temp_c, - 'qswitch_c': self._qswitch_temp_c, - 'power_stage_c': self._power_stage_temp_c - }, - 'status_value': self._status.value, - 'has_errors': self._status.has_errors(), - 'controller_serial': self._controller_serial, - 'head_serial': self._head_serial - } - - def update_status(self): - """Update status information from hardware""" - if not self.is_connected(): - return - - self.query_status() - time.sleep(0.5) - self.query_power_monitor_mw() - time.sleep(0.5) - self.query_all_temperatures() diff --git a/nuescan/hardware/helios_protocol.py b/nuescan/hardware/helios_protocol.py deleted file mode 100644 index 34a0908..0000000 --- a/nuescan/hardware/helios_protocol.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -Helios Laser Protocol Handler -ASCII-based RS-232 communication protocol for Helios laser systems - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -from enum import IntEnum -from typing import Union, Optional - - -class PulseMode(IntEnum): - """Helios pulse mode settings""" - SINGLE_PULSE = 1 - GATING = 4 - CONTINUOUS_PULSING = 14 - - -class HeliosCommand: - """ - Helios laser command constants and builders - - All commands are ASCII strings terminated with carriage return - Format: COMMAND value for setting - COMMAND 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) diff --git a/nuescan/hardware/microscope.py b/nuescan/hardware/microscope.py deleted file mode 100644 index f86f30b..0000000 --- a/nuescan/hardware/microscope.py +++ /dev/null @@ -1,387 +0,0 @@ -""" -Microscope Controller -Handles communication with Genesis and Helios microscope/laser systems - -Genesis: Laser scanning microscope system (stub) -Helios: Laser control system with frequency and current control (full implementation) -Both systems communicate via USB/Serial interfaces - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import time -from typing import Dict, Optional -from hardware.helios_driver import HeliosDriver, PulseMode - - -class MicroscopeController: - """ - Controller for Genesis and Helios microscope systems - - Provides methods for: - - System connection and initialization - - Interlock status monitoring - - Parameter configuration - - Safety checks - """ - - def __init__(self): - """Initialize microscope controller""" - # Genesis state (stub) - self._genesis_connected = False - self._genesis_ready = False - self._genesis_interlocked = False - self._genesis_power_mw = 0.0 - - # Helios driver (full implementation) - self._helios_driver = HeliosDriver() - self._helios_port = None - - # System interlock (master safety) - self._system_interlocked = False - - print("INFO: Microscope controller initialized") - print(" - Genesis: Stub implementation") - print(" - Helios: Full RS-232 driver") - - # ==================== Genesis Methods ==================== - - def connect_genesis(self) -> bool: - """ - Connect to Genesis laser scanning microscope - - Returns: - bool: True if connection successful - """ - print("DEBUG: Connecting to Genesis microscope") - - # Stub implementation - time.sleep(0.1) - - self._genesis_connected = True - self._genesis_ready = True - self._genesis_interlocked = True # Assume interlocks OK - - print("INFO: Genesis microscope connected") - return True - - def disconnect_genesis(self) -> bool: - """ - Disconnect from Genesis microscope - - Returns: - bool: True if disconnection successful - """ - print("DEBUG: Disconnecting from Genesis microscope") - - self._genesis_connected = False - self._genesis_ready = False - - print("INFO: Genesis microscope disconnected") - return True - - def apply_genesis_settings(self, settings: Dict) -> bool: - """ - Apply Genesis configuration settings - - Args: - settings: Dictionary containing Genesis parameters - - Returns: - bool: True if settings applied successfully - """ - print("DEBUG: Applying Genesis settings") - print(f" Power: {settings.get('power_mw', 0)} mW") - - if not self._genesis_connected: - print("ERROR: Genesis not connected") - return False - - self._genesis_power_mw = settings.get('power_mw', 0.0) - - # Stub - would send commands to actual hardware - time.sleep(0.05) - - print("INFO: Genesis settings applied") - return True - - def get_genesis_status(self) -> Dict[str, any]: - """ - Get Genesis microscope status - - Returns: - dict: Genesis status information - """ - return { - 'connected': self._genesis_connected, - 'ready': self._genesis_ready, - 'interlocked': self._genesis_interlocked, - 'power_mw': self._genesis_power_mw - } - - # ==================== Helios Methods ==================== - - def connect_helios(self, port: str) -> bool: - """ - Connect to Helios laser system - - Args: - port: COM port for Helios device - - Returns: - bool: True if connection successful - """ - print(f"DEBUG: Connecting to Helios on {port}") - - if not self._helios_driver.connect(port): - return False - - self._helios_port = port - print(f"INFO: Helios connected on {port}") - print(f" Controller S/N: {self._helios_driver.get_controller_serial()}") - print(f" Head S/N: {self._helios_driver.get_head_serial()}") - return True - - def disconnect_helios(self) -> bool: - """ - Disconnect from Helios laser system - - Returns: - bool: True if disconnection successful - """ - print("DEBUG: Disconnecting from Helios") - return self._helios_driver.disconnect() - - def is_helios_connected(self) -> bool: - """Check if Helios is connected""" - return self._helios_driver.is_connected() - - def apply_helios_settings(self, settings: Dict) -> bool: - """ - Apply Helios configuration settings - - This method connects and configures the Helios laser with the - specified parameters from the settings dialog. - - Args: - settings: Dictionary containing: - - com_port: COM port name - - frequency_hz: Laser frequency in Hz - - current_ma: Laser diode current in mA - - Returns: - bool: True if settings applied successfully - """ - if settings is None: - print("ERROR: No settings provided") - return False - - print("DEBUG: Applying Helios settings") - print(f" Port: {settings.get('com_port', 'N/A')}") - print(f" Frequency: {settings.get('frequency_hz', 0)} Hz") - print(f" Current: {settings.get('current_ma', 0)} mA") - - # Connect if not already connected or port changed - port = settings.get('com_port') - if not port: - print("ERROR: No COM port specified") - return False - - if not self.is_helios_connected() or port != self._helios_port: - if self.is_helios_connected(): - self.disconnect_helios() - if not self.connect_helios(port): - return False - - # Set frequency - freq_hz = settings.get('frequency_hz', 0.0) - if freq_hz > 0: - if not self._helios_driver.set_frequency_hz(freq_hz): - print("ERROR: Failed to set frequency") - return False - time.sleep(0.05) - - # Set current - current_ma = settings.get('current_ma', 0.0) - if current_ma > 0: - if not self._helios_driver.set_current_ma(current_ma): - print("ERROR: Failed to set current") - return False - time.sleep(0.05) - - # Set to continuous pulsing mode by default - if not self._helios_driver.set_pulse_mode(PulseMode.CONTINUOUS_PULSING): - print("WARNING: Failed to set pulse mode") - - print("INFO: Helios settings applied successfully") - return True - - def helios_enable_laser(self, enabled: bool) -> bool: - """ - Enable or disable Helios laser - - Args: - enabled: True to enable, False to disable - - Returns: - bool: True if command successful - """ - if not self.is_helios_connected(): - print("ERROR: Helios not connected") - return False - - return self._helios_driver.set_laser_enable(enabled) - - def is_helios_laser_enabled(self) -> bool: - """Check if Helios laser is currently enabled""" - if not self.is_helios_connected(): - return False - return self._helios_driver.is_laser_enabled() - - def get_helios_status(self) -> Dict[str, any]: - """ - Get Helios laser system status - - Returns: - dict: Helios status information - """ - if not self.is_helios_connected(): - return { - 'connected': False, - 'ready': False, - 'interlocked': False, - 'port': None, - 'frequency_hz': 0.0, - 'current_ma': 0.0, - 'laser_enabled': False, - 'power_mw': 0.0 - } - - # Get comprehensive status from driver - status = self._helios_driver.get_status() - - return { - 'connected': status['connected'], - 'ready': not status['has_errors'], - 'interlocked': not status['has_errors'], # Use error state as interlock - 'port': self._helios_port, - 'frequency_hz': status['frequency_hz'], - 'current_ma': status['current_ma'], - 'laser_enabled': status['laser_enabled'], - 'power_mw': status['power_mw'], - 'operation_hours': status['operation_hours'], - 'controller_serial': status['controller_serial'], - 'head_serial': status['head_serial'] - } - - def helios_update_status(self): - """Update Helios status from hardware""" - if self.is_helios_connected(): - self._helios_driver.update_status() - - # ==================== Combined Status Methods ==================== - - def get_status(self) -> Dict[str, any]: - """ - Get complete microscope system status - - Returns: - dict: Combined status for Genesis, Helios, and interlocks - """ - # Get Helios status from driver - helios_status = self.get_helios_status() - helios_ready = helios_status.get('ready', False) - helios_interlocked = helios_status.get('interlocked', False) - - return { - # System-wide - 'interlocked': self._system_interlocked or ( - self._genesis_interlocked and helios_interlocked - ), - - # Genesis - 'genesis_ready': self._genesis_ready, - 'genesis_interlocked': self._genesis_interlocked, - - # Helios - 'helios_ready': helios_ready, - 'helios_interlocked': helios_interlocked - } - - def check_interlocks(self) -> bool: - """ - Check all safety interlocks - - Returns: - bool: True if all interlocks are satisfied - """ - # Check Genesis interlocks - if self._genesis_connected and not self._genesis_interlocked: - print("WARNING: Genesis interlock not satisfied") - return False - - # Check Helios interlocks - if self.is_helios_connected(): - helios_status = self.get_helios_status() - if not helios_status.get('interlocked', False): - print("WARNING: Helios interlock not satisfied") - return False - - return True - - # ==================== Scan Preparation ==================== - - def prepare_for_scan(self, params: Dict) -> bool: - """ - Prepare microscope systems for scanning - - Args: - params: Scan parameters dictionary - - Returns: - bool: True if preparation successful - """ - print("DEBUG: Preparing microscope systems for scan") - - # Check interlocks - if not self.check_interlocks(): - print("ERROR: Interlock check failed") - return False - - # Verify systems are ready - if self._genesis_connected and not self._genesis_ready: - print("ERROR: Genesis not ready") - return False - - if self.is_helios_connected(): - helios_status = self.get_helios_status() - if not helios_status.get('ready', False): - print("ERROR: Helios not ready") - return False - - # Configure for scan - # Stub implementation - time.sleep(0.1) - - print("INFO: Microscope systems ready for scan") - return True - - def emergency_stop(self) -> bool: - """ - Emergency stop all microscope operations - - Returns: - bool: True if stop successful - """ - print("WARNING: Emergency stop triggered") - - # Disable all systems - if self._genesis_connected: - self._genesis_ready = False - - if self.is_helios_connected(): - # Disable Helios laser immediately - self._helios_driver.set_laser_enable(False) - - return True diff --git a/nuescan/hardware/stage_settings.py b/nuescan/hardware/stage_settings.py deleted file mode 100644 index be11462..0000000 --- a/nuescan/hardware/stage_settings.py +++ /dev/null @@ -1,244 +0,0 @@ -""" -ThorLabs Stage Settings Management -Handles saving and loading of stage configuration parameters - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import json -import os -from typing import Dict, Optional -from pathlib import Path - - -class StageSettings: - """ - Manages stage configuration settings including velocity, acceleration, - and trigger I/O configuration - """ - - DEFAULT_SETTINGS = { - 'velocity': { - 'x_axis': 1.0, # mm/s - 'y_axis': 1.0, # mm/s - 'z_axis': 1.0, # mm/s - }, - 'acceleration': { - 'x_axis': 5.0, # mm/s² - 'y_axis': 5.0, # mm/s² - 'z_axis': 5.0, # mm/s² - }, - 'trigger': { - 'x_axis': { - 'mode': 0x00, # Disabled - 'polarity': 0x01, # Active high - 'start_pos_fwd': 0.0, - 'start_pos_rev': 0.0, - 'interval_fwd': 0.0, - 'interval_rev': 0.0 - }, - 'y_axis': { - 'mode': 0x00, # Disabled - 'polarity': 0x01, # Active high - 'start_pos_fwd': 0.0, - 'start_pos_rev': 0.0, - 'interval_fwd': 0.0, - 'interval_rev': 0.0 - }, - 'z_axis': { - 'mode': 0x00, # Disabled - 'polarity': 0x01, # Active high - 'start_pos_fwd': 0.0, - 'start_pos_rev': 0.0, - 'interval_fwd': 0.0, - 'interval_rev': 0.0 - } - }, - 'digital_io': { - 'output_bits': 0x00 - } - } - - def __init__(self, settings_file: Optional[str] = None): - """ - Initialize stage settings manager - - Args: - settings_file: Path to settings file (default: ~/.nuescan/stage_settings.json) - """ - if settings_file is None: - # Default to user home directory - home = Path.home() - settings_dir = home / '.nuescan' - settings_dir.mkdir(exist_ok=True) - settings_file = str(settings_dir / 'stage_settings.json') - - self.settings_file = settings_file - self.settings = self.DEFAULT_SETTINGS.copy() - - # Load existing settings if available - self.load() - - def load(self) -> bool: - """ - Load settings from file - - Returns: - bool: True if settings loaded successfully, False otherwise - """ - if not os.path.exists(self.settings_file): - print(f"INFO: Settings file not found, using defaults: {self.settings_file}") - return False - - try: - with open(self.settings_file, 'r') as f: - loaded_settings = json.load(f) - - # Merge with defaults to ensure all keys exist - self._merge_settings(loaded_settings) - - print(f"INFO: Loaded stage settings from {self.settings_file}") - return True - - except Exception as e: - print(f"ERROR: Failed to load settings from {self.settings_file}: {e}") - return False - - def save(self) -> bool: - """ - Save settings to file - - Returns: - bool: True if settings saved successfully, False otherwise - """ - try: - # Ensure directory exists - os.makedirs(os.path.dirname(self.settings_file), exist_ok=True) - - with open(self.settings_file, 'w') as f: - json.dump(self.settings, f, indent=4) - - print(f"INFO: Saved stage settings to {self.settings_file}") - return True - - except Exception as e: - print(f"ERROR: Failed to save settings to {self.settings_file}: {e}") - return False - - def _merge_settings(self, loaded_settings: Dict): - """Merge loaded settings with defaults""" - # Velocity - if 'velocity' in loaded_settings: - self.settings['velocity'].update(loaded_settings['velocity']) - - # Acceleration - if 'acceleration' in loaded_settings: - self.settings['acceleration'].update(loaded_settings['acceleration']) - - # Trigger - if 'trigger' in loaded_settings: - for axis in ['x_axis', 'y_axis', 'z_axis']: - if axis in loaded_settings['trigger']: - self.settings['trigger'][axis].update(loaded_settings['trigger'][axis]) - - # Digital I/O - if 'digital_io' in loaded_settings: - self.settings['digital_io'].update(loaded_settings['digital_io']) - - # ==================== Velocity Settings ==================== - - def get_velocity(self, axis: str) -> float: - """Get velocity for specific axis (x_axis, y_axis, z_axis)""" - return self.settings['velocity'].get(axis, 1.0) - - def set_velocity(self, axis: str, velocity: float): - """Set velocity for specific axis""" - if axis in ['x_axis', 'y_axis', 'z_axis']: - self.settings['velocity'][axis] = velocity - - def get_all_velocities(self) -> Dict[str, float]: - """Get all axis velocities""" - return self.settings['velocity'].copy() - - def set_all_velocities(self, x: float, y: float, z: float): - """Set all axis velocities""" - self.settings['velocity']['x_axis'] = x - self.settings['velocity']['y_axis'] = y - self.settings['velocity']['z_axis'] = z - - # ==================== Acceleration Settings ==================== - - def get_acceleration(self, axis: str) -> float: - """Get acceleration for specific axis""" - return self.settings['acceleration'].get(axis, 5.0) - - def set_acceleration(self, axis: str, acceleration: float): - """Set acceleration for specific axis""" - if axis in ['x_axis', 'y_axis', 'z_axis']: - self.settings['acceleration'][axis] = acceleration - - def get_all_accelerations(self) -> Dict[str, float]: - """Get all axis accelerations""" - return self.settings['acceleration'].copy() - - def set_all_accelerations(self, x: float, y: float, z: float): - """Set all axis accelerations""" - self.settings['acceleration']['x_axis'] = x - self.settings['acceleration']['y_axis'] = y - self.settings['acceleration']['z_axis'] = z - - # ==================== Trigger Settings ==================== - - def get_trigger_config(self, axis: str) -> Dict: - """Get trigger configuration for specific axis""" - return self.settings['trigger'].get(axis, {}).copy() - - def set_trigger_config(self, axis: str, mode: int, polarity: int = 0x01, - start_pos_fwd: float = 0.0, start_pos_rev: float = 0.0, - interval_fwd: float = 0.0, interval_rev: float = 0.0): - """Set trigger configuration for specific axis""" - if axis in ['x_axis', 'y_axis', 'z_axis']: - self.settings['trigger'][axis] = { - 'mode': mode, - 'polarity': polarity, - 'start_pos_fwd': start_pos_fwd, - 'start_pos_rev': start_pos_rev, - 'interval_fwd': interval_fwd, - 'interval_rev': interval_rev - } - - def get_trigger_mode(self, axis: str) -> int: - """Get trigger mode for specific axis""" - return self.settings['trigger'].get(axis, {}).get('mode', 0x00) - - def set_trigger_mode(self, axis: str, mode: int): - """Set trigger mode for specific axis""" - if axis in ['x_axis', 'y_axis', 'z_axis']: - if axis not in self.settings['trigger']: - self.settings['trigger'][axis] = self.DEFAULT_SETTINGS['trigger'][axis].copy() - self.settings['trigger'][axis]['mode'] = mode - - # ==================== Digital I/O Settings ==================== - - def get_digital_outputs(self) -> int: - """Get digital output bits""" - return self.settings['digital_io'].get('output_bits', 0x00) - - def set_digital_outputs(self, output_bits: int): - """Set digital output bits""" - self.settings['digital_io']['output_bits'] = output_bits - - # ==================== Utility Methods ==================== - - def reset_to_defaults(self): - """Reset all settings to defaults""" - self.settings = self.DEFAULT_SETTINGS.copy() - - def get_all_settings(self) -> Dict: - """Get copy of all settings""" - return json.loads(json.dumps(self.settings)) # Deep copy via JSON - - def update_from_dict(self, settings_dict: Dict): - """Update settings from dictionary""" - self._merge_settings(settings_dict) diff --git a/nuescan/hardware/t3r_device.py b/nuescan/hardware/t3r_device.py deleted file mode 100644 index 480a6dd..0000000 --- a/nuescan/hardware/t3r_device.py +++ /dev/null @@ -1,259 +0,0 @@ -""" -T3R-SL Device Controller -Handles communication with T3R-SL device via USB/Serial - -The T3R-SL is a specialized instrument control device that provides -timing, triggering, and coordination for the SRAS scanning system. -""" - -import time -from typing import Dict, List, Optional - - -class T3RDevice: - """ - Controller for T3R-SL device - - Provides methods for: - - Connecting/disconnecting - - Device initialization and homing - - Status monitoring - - Trigger and timing control - """ - - def __init__(self): - """Initialize T3R device controller""" - self._connected = False - self._port = None - self._homed = False - self._ready = False - - # Device state - self._initialized = False - self._error_state = False - - print("INFO: T3R-SL Device controller initialized (stub)") - - def get_available_ports(self) -> List[str]: - """ - Get list of available COM ports - - Returns: - list: Available port names - """ - # Stub implementation - return dummy ports - # In production, would scan for actual serial ports - return [ - "COM1", "COM2", "COM3", "COM4", "COM5", - "/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyUSB2" - ] - - def connect(self, port: str) -> bool: - """ - Connect to T3R device on specified port - - Args: - port: COM port name - - Returns: - bool: True if connection successful - """ - print(f"DEBUG: Connecting to T3R device on {port}") - - # Stub implementation - time.sleep(0.1) - - self._port = port - self._connected = True - self._ready = False # Need to initialize after connect - - print(f"INFO: Connected to T3R device on {port}") - - # Auto-initialize - return self._initialize() - - def disconnect(self) -> bool: - """ - Disconnect from T3R device - - Returns: - bool: True if disconnection successful - """ - print("DEBUG: Disconnecting from T3R device") - - self._connected = False - self._homed = False - self._ready = False - self._initialized = False - - print("INFO: Disconnected from T3R device") - return True - - def is_connected(self) -> bool: - """Check if device is connected""" - return self._connected - - def _initialize(self) -> bool: - """ - Initialize T3R device after connection - - Returns: - bool: True if initialization successful - """ - print("DEBUG: Initializing T3R device") - - if not self._connected: - print("ERROR: Cannot initialize - device not connected") - return False - - # Stub - simulate initialization - time.sleep(0.2) - - self._initialized = True - self._error_state = False - - # Perform homing - return self.home() - - def home(self) -> bool: - """ - Home/zero T3R device - - Returns: - bool: True if homing successful - """ - print("DEBUG: Homing T3R device") - - if not self._connected or not self._initialized: - print("ERROR: Cannot home - device not initialized") - return False - - # Stub - simulate homing - time.sleep(0.3) - - self._homed = True - self._ready = True - - print("INFO: T3R device homed successfully") - return True - - def get_status(self) -> Dict[str, bool]: - """ - Get current device status - - Returns: - dict: Status information - """ - return { - 'connected': self._connected, - 'initialized': self._initialized, - 'homed': self._homed, - 'ready': self._ready, - 'error': self._error_state - } - - def prepare_for_scan(self, params: Dict) -> bool: - """ - Prepare T3R device for scanning operation - - Args: - params: Scan parameters dictionary - - Returns: - bool: True if preparation successful - """ - print("DEBUG: Preparing T3R device for scan") - print(f" Number of scans: {params.get('num_scans', 1)}") - print(f" Trigger voltage: {params.get('trigger_voltage', 0)}V") - - if not self._ready: - print("ERROR: T3R device not ready for scanning") - return False - - # Configure device for scan parameters - # Stub implementation - time.sleep(0.1) - - print("INFO: T3R device ready for scanning") - return True - - def trigger_acquisition(self) -> bool: - """ - Trigger a data acquisition event - - Returns: - bool: True if trigger successful - """ - if not self._ready: - print("ERROR: Cannot trigger - device not ready") - return False - - print("DEBUG: Triggering acquisition") - # Stub - would send trigger command - return True - - def read_position(self) -> Optional[float]: - """ - Read current position from T3R device - - Returns: - float: Current position value, or None if error - """ - if not self._ready: - return None - - # Stub - return dummy position - return 0.0 - - def set_timing_parameters(self, acquisition_time: float, - delay_time: float) -> bool: - """ - Set timing parameters for acquisition - - Args: - acquisition_time: Acquisition window time in seconds - delay_time: Delay before acquisition in seconds - - Returns: - bool: True if parameters set successfully - """ - print(f"DEBUG: Setting timing - acq: {acquisition_time}s, delay: {delay_time}s") - - if not self._connected: - print("ERROR: Device not connected") - return False - - # Stub implementation - return True - - def get_error_status(self) -> Dict[str, any]: - """ - Get detailed error status - - Returns: - dict: Error status information - """ - return { - 'has_error': self._error_state, - 'error_code': 0, - 'error_message': 'No error' - } - - def reset(self) -> bool: - """ - Reset T3R device to initial state - - Returns: - bool: True if reset successful - """ - print("DEBUG: Resetting T3R device") - - if not self._connected: - return False - - self._error_state = False - self._homed = False - self._ready = False - - # Re-initialize - return self._initialize() diff --git a/nuescan/hardware/thorlabs_stage.py b/nuescan/hardware/thorlabs_stage.py deleted file mode 100644 index 2730de2..0000000 --- a/nuescan/hardware/thorlabs_stage.py +++ /dev/null @@ -1,651 +0,0 @@ -""" -ThorLabs MLS Stage Controller -Handles communication with ThorLabs MLS 3-axis positioning stage via BBD203 motor controller - -The ThorLabs MLS stage provides precision X/Y/Z positioning for scanning operations. -This implementation uses the BBD203 3-channel motor controller with the APT protocol. - -Channel Mapping: -- Channel 1: X-axis -- Channel 2: Y-axis -- Channel 3: Z-axis (optional) - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import time -from typing import Dict, Optional - -from hardware.bbd203_driver import BBD203Driver -from hardware.bbd203_protocol import TriggerMode -from hardware.stage_settings import StageSettings - - -class ThorLabsStage: - """ - Controller for ThorLabs MLS positioning stage using BBD203 motor controller - - Provides methods for: - - Connecting/disconnecting from stage - - Homing axes - - Position control and readout - - Status monitoring - """ - - # Channel mapping - X_AXIS = 1 - Y_AXIS = 2 - Z_AXIS = 3 - - def __init__(self, encoder_counts_per_mm: int = 20000, settings_file: Optional[str] = None): - """ - Initialize ThorLabs stage controller - - Args: - encoder_counts_per_mm: Encoder resolution (default: 20000 for MLS203) - settings_file: Path to settings file (default: ~/.nuescan/stage_settings.json) - """ - self._driver = BBD203Driver(encoder_counts_per_mm) - self._port = None - - # Settings manager - self.settings = StageSettings(settings_file) - - # Scanning state - self._scanning = False - - # Default velocity and acceleration (can be overridden by settings) - self._default_velocity = 1.0 # mm/s - self._default_accel = 5.0 # mm/s² - - print("INFO: ThorLabs Stage controller initialized (BBD203 driver)") - - def connect(self, serial_number: str, baudrate: int = 115200) -> bool: - """ - Connect to ThorLabs stage via BBD203 controller using serial number - - The serial number is printed on the BBD203 controller (e.g., '83123456'). - The driver will automatically find the USB device and connect. - - Args: - serial_number: BBD203 device serial number - baudrate: Baud rate (default: 115200) - - Returns: - bool: True if connection successful - """ - print(f"DEBUG: Connecting to BBD203/MLS Stage with serial number {serial_number}") - - # Connect by serial number - driver will auto-find the port - if not self._driver.connect_by_serial(serial_number, baudrate): - return False - - self._port = serial_number # Store serial for reference - - # Enable all channels - time.sleep(0.2) - self._driver.enable_channel(self.X_AXIS, True) - time.sleep(0.1) - self._driver.enable_channel(self.Y_AXIS, True) - time.sleep(0.1) - self._driver.enable_channel(self.Z_AXIS, True) - time.sleep(0.1) - - # Apply startup settings from configuration - self.apply_startup_settings() - - print("INFO: Stage connected and channels enabled") - return True - - def disconnect(self) -> bool: - """ - Disconnect from ThorLabs stage - - Returns: - bool: True if disconnection successful - """ - self._scanning = False - return self._driver.disconnect() - - def is_connected(self) -> bool: - """Check if stage is connected""" - return self._driver.is_connected() - - # ==================== Homing ==================== - - def home_all_axes(self, wait: bool = True, timeout: float = 60.0) -> bool: - """ - Home all axes (X, Y, Z) - - Args: - wait: If True, block until homing complete - timeout: Timeout in seconds - - Returns: - bool: True if homing successful - """ - print("DEBUG: Homing all axes") - - if not self.is_connected(): - print("ERROR: Cannot home - stage not connected") - return False - - return self._driver.home_all_channels(wait=wait, timeout=timeout) - - def home_axis(self, axis: str, wait: bool = True, timeout: float = 30.0) -> bool: - """ - Home a specific axis - - Args: - axis: Axis to home ('X', 'Y', or 'Z') - wait: If True, block until homing complete - timeout: Timeout in seconds - - Returns: - bool: True if homing successful - """ - axis = axis.upper() - if axis not in ['X', 'Y', 'Z']: - print(f"ERROR: Invalid axis: {axis}") - return False - - channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis] - - print(f"DEBUG: Homing {axis} axis (channel {channel})") - - return self._driver.home_channel(channel, wait=wait, timeout=timeout) - - # ==================== Motion Control ==================== - - def move_absolute(self, x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, - wait: bool = False) -> bool: - """ - Move to absolute position - - Args: - x: X position in mm (None to leave unchanged) - y: Y position in mm (None to leave unchanged) - z: Z position in mm (None to leave unchanged) - wait: If True, block until move complete - - Returns: - bool: True if move successful - """ - if not self.is_connected(): - print("ERROR: Stage not connected") - return False - - success = True - - # Move each axis that was specified - if x is not None: - if not self._driver.move_absolute(self.X_AXIS, x, wait=wait): - success = False - - if y is not None: - if not self._driver.move_absolute(self.Y_AXIS, y, wait=wait): - success = False - - if z is not None: - if not self._driver.move_absolute(self.Z_AXIS, z, wait=wait): - success = False - - return success - - def move_relative(self, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0, - wait: bool = False) -> bool: - """ - Move relative to current position - - Args: - dx: X displacement in mm - dy: Y displacement in mm - dz: Z displacement in mm - wait: If True, block until move complete - - Returns: - bool: True if move successful - """ - if not self.is_connected(): - print("ERROR: Stage not connected") - return False - - success = True - - if dx != 0.0: - if not self._driver.move_relative(self.X_AXIS, dx, wait=wait): - success = False - - if dy != 0.0: - if not self._driver.move_relative(self.Y_AXIS, dy, wait=wait): - success = False - - if dz != 0.0: - if not self._driver.move_relative(self.Z_AXIS, dz, wait=wait): - success = False - - return success - - def stop_all(self, immediate: bool = True) -> bool: - """ - Stop all motion - - Args: - immediate: If True, stop immediately; if False, decelerate - - Returns: - bool: True if stop successful - """ - return self._driver.stop(0, immediate) # Channel 0 = all channels - - # ==================== Position and Status ==================== - - def get_position(self) -> Dict[str, float]: - """ - Get current position - - Returns: - dict: Current X, Y, Z positions in mm - """ - return { - 'x': self._driver.get_position(self.X_AXIS) or 0.0, - 'y': self._driver.get_position(self.Y_AXIS) or 0.0, - 'z': self._driver.get_position(self.Z_AXIS) or 0.0 - } - - def get_status(self) -> Dict[str, bool]: - """ - Get current stage status - - Returns: - dict: Status information compatible with main_window expectations - """ - x_status = self._driver.get_channel_status(self.X_AXIS) or {} - y_status = self._driver.get_channel_status(self.Y_AXIS) or {} - z_status = self._driver.get_channel_status(self.Z_AXIS) or {} - - # Determine if any axis is moving - moving = (x_status.get('moving', False) or - y_status.get('moving', False) or - z_status.get('moving', False)) - - # Determine if stage is ready (all enabled axes are homed) - x_ready = x_status.get('enabled', False) and x_status.get('homed', False) - y_ready = y_status.get('enabled', False) and y_status.get('homed', False) - ready = x_ready and y_ready # Z is optional - - return { - 'connected': self.is_connected(), - 'x_homed': x_status.get('homed', False), - 'y_homed': y_status.get('homed', False), - 'z_homed': z_status.get('homed', False), - 'ready': ready, - 'scanning': self._scanning, - 'moving': moving - } - - # ==================== Velocity Control ==================== - - def set_velocity(self, velocity_mm_s: float, accel_mm_s2: float, - axis: Optional[str] = None) -> bool: - """ - Set velocity and acceleration parameters - - Args: - velocity_mm_s: Maximum velocity in mm/s - accel_mm_s2: Acceleration in mm/s² - axis: Specific axis ('X', 'Y', 'Z'), or None for all axes - - Returns: - bool: True if parameters set successfully - """ - if axis: - axis = axis.upper() - if axis not in ['X', 'Y', 'Z']: - print(f"ERROR: Invalid axis: {axis}") - return False - - channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis] - return self._driver.set_velocity_params(channel, velocity_mm_s, accel_mm_s2) - else: - # Set for all axes - success = True - for channel in [self.X_AXIS, self.Y_AXIS, self.Z_AXIS]: - if not self._driver.set_velocity_params(channel, velocity_mm_s, accel_mm_s2): - success = False - time.sleep(0.05) - return success - - # ==================== Scan Support ==================== - - def prepare_for_scan(self, params: Dict) -> bool: - """ - Prepare stage for scanning operation - - Args: - params: Scan parameters dictionary - - Returns: - bool: True if preparation successful - """ - print("DEBUG: Preparing stage for scan") - - status = self.get_status() - if not status['ready']: - print("ERROR: Stage not ready for scanning") - return False - - # Move to start position - x_start = params.get('x_start', 0) - y_start = params.get('y_start', 0) - - print(f" Moving to scan start: X={x_start} mm, Y={y_start} mm") - - if not self.move_absolute(x=x_start, y=y_start, wait=True): - print("ERROR: Failed to move to start position") - return False - - self._scanning = True - print("INFO: Stage ready for scanning") - return True - - def stop_scan(self) -> bool: - """ - Stop current scan operation - - Returns: - bool: True if stop successful - """ - print("DEBUG: Stopping scan") - self._scanning = False - return self.stop_all(immediate=True) - - # ==================== Utility Methods ==================== - - def identify(self, channel: Optional[int] = None) -> bool: - """ - Flash front panel LEDs to identify controller - - Args: - channel: Specific channel (1, 2, 3), or None for all - - Returns: - bool: True if command sent successfully - """ - if channel is None: - # Identify all channels - for ch in [1, 2, 3]: - self._driver.identify(ch) - time.sleep(0.1) - return True - else: - return self._driver.identify(channel) - - @staticmethod - def list_available_ports(): - """ - List available serial ports (deprecated - use list_devices instead) - - Returns: - list: Available port names - """ - return BBD203Driver.list_available_ports() - - @staticmethod - def list_devices(): - """ - List all connected ThorLabs BBD203 devices - - Returns: - list: List of dicts with device info including 'serial' and 'port' - """ - return BBD203Driver.list_thorlabs_devices() - - # ==================== Settings Management ==================== - - def apply_startup_settings(self) -> bool: - """ - Apply saved settings to the stage on startup - - This includes: - - Velocity parameters for all axes - - Acceleration parameters for all axes - - Trigger configuration for all axes - - Returns: - bool: True if all settings applied successfully - """ - print("INFO: Applying startup settings to stage") - - success = True - - # Apply velocity and acceleration settings - velocities = self.settings.get_all_velocities() - accelerations = self.settings.get_all_accelerations() - - print(f" Velocity settings: X={velocities['x_axis']} mm/s, " - f"Y={velocities['y_axis']} mm/s, Z={velocities['z_axis']} mm/s") - print(f" Acceleration settings: X={accelerations['x_axis']} mm/s², " - f"Y={accelerations['y_axis']} mm/s², Z={accelerations['z_axis']} mm/s²") - - # Set velocity/acceleration for each axis - if not self._driver.set_velocity_params( - self.X_AXIS, velocities['x_axis'], accelerations['x_axis'] - ): - success = False - time.sleep(0.05) - - if not self._driver.set_velocity_params( - self.Y_AXIS, velocities['y_axis'], accelerations['y_axis'] - ): - success = False - time.sleep(0.05) - - if not self._driver.set_velocity_params( - self.Z_AXIS, velocities['z_axis'], accelerations['z_axis'] - ): - success = False - time.sleep(0.05) - - # Apply trigger configuration for each axis - for axis_name, channel in [('x_axis', self.X_AXIS), - ('y_axis', self.Y_AXIS), - ('z_axis', self.Z_AXIS)]: - trigger_config = self.settings.get_trigger_config(axis_name) - - if not self._driver.set_trigger_mode( - channel, - trigger_config['mode'], - trigger_config['polarity'], - trigger_config['start_pos_fwd'], - trigger_config['start_pos_rev'], - trigger_config['interval_fwd'], - trigger_config['interval_rev'] - ): - success = False - time.sleep(0.05) - - if success: - print("INFO: All startup settings applied successfully") - else: - print("WARNING: Some startup settings failed to apply") - - return success - - def save_current_settings(self) -> bool: - """ - Save current settings to file - - Returns: - bool: True if saved successfully - """ - return self.settings.save() - - def reload_settings(self) -> bool: - """ - Reload settings from file - - Returns: - bool: True if reloaded successfully - """ - return self.settings.load() - - def configure_velocity(self, x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, - save: bool = True) -> bool: - """ - Configure velocity for one or more axes - - Args: - x: X-axis velocity in mm/s (None to keep current) - y: Y-axis velocity in mm/s (None to keep current) - z: Z-axis velocity in mm/s (None to keep current) - save: Save settings to file after updating - - Returns: - bool: True if configuration successful - """ - success = True - - if x is not None: - self.settings.set_velocity('x_axis', x) - accel = self.settings.get_acceleration('x_axis') - if self.is_connected(): - success &= self._driver.set_velocity_params(self.X_AXIS, x, accel) - time.sleep(0.05) - - if y is not None: - self.settings.set_velocity('y_axis', y) - accel = self.settings.get_acceleration('y_axis') - if self.is_connected(): - success &= self._driver.set_velocity_params(self.Y_AXIS, y, accel) - time.sleep(0.05) - - if z is not None: - self.settings.set_velocity('z_axis', z) - accel = self.settings.get_acceleration('z_axis') - if self.is_connected(): - success &= self._driver.set_velocity_params(self.Z_AXIS, z, accel) - time.sleep(0.05) - - if save: - self.settings.save() - - return success - - def configure_acceleration(self, x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, - save: bool = True) -> bool: - """ - Configure acceleration for one or more axes - - Args: - x: X-axis acceleration in mm/s² (None to keep current) - y: Y-axis acceleration in mm/s² (None to keep current) - z: Z-axis acceleration in mm/s² (None to keep current) - save: Save settings to file after updating - - Returns: - bool: True if configuration successful - """ - success = True - - if x is not None: - self.settings.set_acceleration('x_axis', x) - vel = self.settings.get_velocity('x_axis') - if self.is_connected(): - success &= self._driver.set_velocity_params(self.X_AXIS, vel, x) - time.sleep(0.05) - - if y is not None: - self.settings.set_acceleration('y_axis', y) - vel = self.settings.get_velocity('y_axis') - if self.is_connected(): - success &= self._driver.set_velocity_params(self.Y_AXIS, vel, y) - time.sleep(0.05) - - if z is not None: - self.settings.set_acceleration('z_axis', z) - vel = self.settings.get_velocity('z_axis') - if self.is_connected(): - success &= self._driver.set_velocity_params(self.Z_AXIS, vel, z) - time.sleep(0.05) - - if save: - self.settings.save() - - return success - - def configure_trigger(self, axis: str, mode: int, - polarity: int = 0x01, - start_pos_fwd: float = 0.0, - start_pos_rev: float = 0.0, - interval_fwd: float = 0.0, - interval_rev: float = 0.0, - save: bool = True) -> bool: - """ - Configure trigger for specific axis - - Args: - axis: Axis name ('X', 'Y', or 'Z') - mode: Trigger mode (TriggerMode enum value) - polarity: Trigger polarity (0x01 = active high, 0x02 = active low) - start_pos_fwd: Start position for forward trigger (mm) - start_pos_rev: Start position for reverse trigger (mm) - interval_fwd: Interval for forward trigger (mm) - interval_rev: Interval for reverse trigger (mm) - save: Save settings to file after updating - - Returns: - bool: True if configuration successful - """ - axis = axis.upper() - if axis not in ['X', 'Y', 'Z']: - print(f"ERROR: Invalid axis: {axis}") - return False - - axis_name = f"{axis.lower()}_axis" - channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis] - - # Update settings - self.settings.set_trigger_config( - axis_name, mode, polarity, - start_pos_fwd, start_pos_rev, - interval_fwd, interval_rev - ) - - # Apply to hardware if connected - success = True - if self.is_connected(): - success = self._driver.set_trigger_mode( - channel, mode, polarity, - start_pos_fwd, start_pos_rev, - interval_fwd, interval_rev - ) - - if save: - self.settings.save() - - return success - - def get_detailed_status(self) -> Dict: - """ - Get detailed status of all channels - - Returns: - dict: Detailed status information - """ - return { - 'connected': self.is_connected(), - 'scanning': self._scanning, - 'x_axis': self._driver.get_channel_status(self.X_AXIS), - 'y_axis': self._driver.get_channel_status(self.Y_AXIS), - 'z_axis': self._driver.get_channel_status(self.Z_AXIS), - 'position': self.get_position(), - 'settings': self.settings.get_all_settings() - } diff --git a/nuescan/main_window.py b/nuescan/main_window.py deleted file mode 100644 index f30a9a1..0000000 --- a/nuescan/main_window.py +++ /dev/null @@ -1,411 +0,0 @@ -""" -nueScan - Main Window Controller -Handles all UI interactions and coordinates hardware communication - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import os -from PyQt6 import uic -from PyQt6.QtWidgets import QMainWindow, QMessageBox -from PyQt6.QtCore import QTimer - -# Import dialog controllers -from dialogs.genesis_dialog import GenesisDialog -from dialogs.helios_dialog import HeliosDialog -from dialogs.scan_active_dialog import ScanActiveDialog -from dialogs.status_dialog import StatusDialog -from dialogs.oscope_dialog import OscopeDialog - -# Import hardware controllers -from hardware.thorlabs_stage import ThorLabsStage -from hardware.t3r_device import T3RDevice -from hardware.microscope import MicroscopeController - - -class NueScanMainWindow(QMainWindow): - """Main window for nueScan application""" - - def __init__(self): - super().__init__() - - # Load UI file - ui_path = os.path.join(os.path.dirname(__file__), 'nuescan_mainwindow.ui') - uic.loadUi(ui_path, self) - - # Set window title - self.setWindowTitle("nueScan - SRAS Scan Planning and Control") - - # Initialize hardware controllers - self.thorlabs_stage = ThorLabsStage() - self.t3r_device = T3RDevice() - self.microscope = MicroscopeController() - - # Initialize dialogs (create on demand) - self.genesis_dialog = None - self.helios_dialog = None - self.scan_active_dialog = None - self.status_dialog = StatusDialog(self, self.thorlabs_stage, self.t3r_device, self.microscope) - self.oscope_dialog = OscopeDialog(self, self.microscope) - - # Status update timer - self.status_timer = QTimer() - self.status_timer.timeout.connect(self._update_all_status) - self.status_timer.start(100) # Update every 100ms - - # Connect all UI signals - self._connect_signals() - - # Initialize UI state - self._initialize_ui() - - def _connect_signals(self): - """Connect all UI signals to handler methods""" - - # ===== Button Click Handlers ===== - self.btn_toggle_mls.clicked.connect(self.on_toggle_mls_clicked) - self.btn_refresh_com.clicked.connect(self.on_refresh_com_clicked) - self.button_connect_com.clicked.connect(self.on_connect_com_clicked) - self.btn_begin_scanning.clicked.connect(self.on_begin_scanning_clicked) - self.btn_toggle_status_window.clicked.connect(self.on_toggle_status_window_clicked) - self.actionShow_Oscope_Settings.triggered.connect(self.on_show_oscope_settings_clicked) - self.actionDigital_IO_State.triggered.connect(self.on_show_digital_io_state_clicked) - self.actionMLS203_Information.triggered.connect(self.on_show_mls203_information_clicked) - self.actionTransfer_System_Editor.triggered.connect(self.on_show_transfer_system_editor_clicked) - self.actionHelios_Settings.triggered.connect(self.on_show_helios_settings_clicked) - self.actionGenesis_Settings.triggered.connect(self.on_show_genesis_settings_clicked) - - # ===== LineEdit Text Changed Handlers ===== - self.le_stage_serial.textChanged.connect(self.on_stage_serial_changed) - self.le_x_start_coord.textChanged.connect(self.on_x_start_coord_changed) - self.le_x_delta.textChanged.connect(self.on_x_delta_changed) - self.le_y_start_coord.textChanged.connect(self.on_y_start_coord_changed) - self.le_y_delta.textChanged.connect(self.on_y_delta_changed) - self.le_file_suffix.textChanged.connect(self.on_file_suffix_changed) - - # ===== ComboBox Value Changed Handlers ===== - self.cb_num_scans.currentIndexChanged.connect(self.on_num_scans_changed) - self.cb_row_spacing.currentIndexChanged.connect(self.on_row_spacing_changed) - - def _initialize_ui(self): - """Initialize UI with default values""" - # Set placeholder text for stage serial - self.le_stage_serial.setPlaceholderText("Enter BBD203 serial (e.g., 83123456)") - - # Refresh COM ports - self.on_refresh_com_clicked() - - # ==================== Button Click Handlers ==================== - - def on_toggle_status_window_clicked(self): - """Toggle the visibility of the status window""" - if self.status_dialog.isVisible(): - self.status_dialog.hide() - else: - self.status_dialog.show() - - def on_show_oscope_settings_clicked(self): - """Toggle the visibility of the oscope settings window""" - if self.oscope_dialog.isVisible(): - self.oscope_dialog.hide() - else: - self.oscope_dialog.show() - - def on_show_digital_io_state_clicked(self): - """Show the digital IO state dialog""" - print("DEBUG: Show digital IO state clicked") - - def on_show_mls203_information_clicked(self): - """Show the MLS203 information dialog""" - print("DEBUG: Show MLS203 information clicked") - - def on_show_transfer_system_editor_clicked(self): - """Show the transfer system editor dialog""" - print("DEBUG: Show transfer system editor clicked") - - def on_toggle_mls_clicked(self): - """Handle ThorLabs MLS stage connect/disconnect""" - print("DEBUG: MLS toggle button clicked") - if self.thorlabs_stage.is_connected(): - self.thorlabs_stage.disconnect() - self.btn_toggle_mls.setText("Connect") - else: - serial_number = self.le_stage_serial.text().strip() - if not serial_number: - QMessageBox.warning( - self, "No Serial Number", - "Please enter the BBD203 serial number.\n\n" - "The serial number is printed on the controller label\n" - "(e.g., '83123456')." - ) - return - - print(f"INFO: Attempting to connect to BBD203 serial: {serial_number}") - success = self.thorlabs_stage.connect(serial_number) - if success: - self.btn_toggle_mls.setText("Disconnect") - QMessageBox.information( - self, "Connected", - f"Successfully connected to BBD203 controller\n" - f"Serial: {serial_number}\n\n" - f"All channels enabled. Ready to home axes." - ) - else: - # Show available devices - devices = self.thorlabs_stage.list_devices() - if devices: - device_list = "\n".join([ - f" Serial: {d['serial']} ({d['description']})" - for d in devices - ]) - msg = (f"Failed to connect to BBD203 with serial: {serial_number}\n\n" - f"Available ThorLabs devices:\n{device_list}") - else: - msg = (f"Failed to connect to BBD203 with serial: {serial_number}\n\n" - f"No ThorLabs devices found.\n" - f"Check USB connection and driver installation.") - - QMessageBox.warning(self, "Connection Error", msg) - - def on_refresh_com_clicked(self): - """Refresh available COM ports""" - print("DEBUG: Refresh COM ports clicked") - self.combo_com_ports.clear() - ports = self.t3r_device.get_available_ports() - self.combo_com_ports.addItems(ports) - - def on_connect_com_clicked(self): - """Connect to selected COM port""" - print("DEBUG: Connect COM button clicked") - port = self.combo_com_ports.currentText() - if port: - success = self.t3r_device.connect(port) - if success: - self.button_connect_com.setText("Disconnect") - else: - QMessageBox.warning(self, "Connection Error", f"Failed to connect to {port}") - else: - QMessageBox.warning(self, "No Port Selected", "Please select a COM port") - - def on_show_helios_settings_clicked(self): - """Show Helios settings dialog""" - print("DEBUG: Show Helios settings clicked") - if not self.helios_dialog: - self.helios_dialog = HeliosDialog(self) - - if self.helios_dialog.exec(): - # User clicked OK, apply settings - settings = self.helios_dialog.get_settings() - self.microscope.apply_helios_settings(settings) - print(f"DEBUG: Applied Helios settings: {settings}") - - def on_show_genesis_settings_clicked(self): - """Show Genesis settings dialog""" - print("DEBUG: Show Genesis settings clicked") - if not self.genesis_dialog: - self.genesis_dialog = GenesisDialog(self) - - if self.genesis_dialog.exec(): - # User clicked OK, apply settings - settings = self.genesis_dialog.get_settings() - self.microscope.apply_genesis_settings(settings) - print(f"DEBUG: Applied Genesis settings: {settings}") - - - def on_begin_scanning_clicked(self): - """Start the scanning process""" - print("DEBUG: Begin scanning clicked") - - # Validate that all systems are ready - if not self._validate_scan_ready(): - return - - # Create and show scan active dialog - if not self.scan_active_dialog: - self.scan_active_dialog = ScanActiveDialog(self) - - # Start the scan - self._start_scan() - - # Show progress dialog - self.scan_active_dialog.exec() - - # ==================== ComboBox Change Handlers ==================== - - def on_num_scans_changed(self, index): - """Handle number of scans change""" - num_scans = self.cb_num_scans.currentText() - print(f"DEBUG: Number of scans changed to: {num_scans}") - self._recalculate_scan_parameters() - - def on_row_spacing_changed(self, index): - """Handle row spacing change""" - spacing = self.cb_row_spacing.currentText() - print(f"DEBUG: Row spacing changed to: {spacing}") - self._recalculate_scan_parameters() - - # ==================== LineEdit Text Changed Handlers ==================== - - def on_stage_serial_changed(self, text): - """Handle stage serial number change""" - print(f"DEBUG: Stage serial changed to: {text}") - - def on_x_start_coord_changed(self, text): - """Handle X start coordinate change""" - print(f"DEBUG: X start coordinate changed to: {text}") - self._recalculate_scan_parameters() - - def on_x_delta_changed(self, text): - """Handle X delta change""" - print(f"DEBUG: X delta changed to: {text}") - self._recalculate_scan_parameters() - - def on_y_start_coord_changed(self, text): - """Handle Y start coordinate change""" - print(f"DEBUG: Y start coordinate changed to: {text}") - self._recalculate_scan_parameters() - - def on_y_delta_changed(self, text): - """Handle Y delta change""" - print(f"DEBUG: Y delta changed to: {text}") - self._recalculate_scan_parameters() - - def on_file_suffix_changed(self, text): - """Handle file suffix change""" - print(f"DEBUG: File suffix changed to: {text}") - - # ==================== Status Update Methods ==================== - - def _update_all_status(self): - """Update all status labels with current hardware states""" - self.status_dialog.update_all_status() - - # ==================== Scan Management Methods ==================== - - def _validate_scan_ready(self): - """Validate that all systems are ready for scanning""" - if not self.thorlabs_stage.is_connected(): - QMessageBox.warning( - self, "Not Ready", "ThorLabs stage is not connected") - return False - - if not self.t3r_device.is_connected(): - QMessageBox.warning(self, "Not Ready", "T3R device is not connected") - return False - - # Add more validation as needed - return True - - def _start_scan(self): - """Initialize and start the scanning process""" - print("DEBUG: Starting scan process") - - # Collect scan parameters - params = self._collect_scan_parameters() - - # Initialize hardware for scanning - self.thorlabs_stage.prepare_for_scan(params) - self.t3r_device.prepare_for_scan(params) - self.microscope.prepare_for_scan(params) - - # Start the scan (would be implemented in actual hardware controllers) - print(f"DEBUG: Scan parameters: {params}") - - def _collect_scan_parameters(self): - """Collect all scan parameters from UI""" - try: - params = { - 'x_start': float(self.le_x_start_coord.text() or 0), - 'x_delta': float(self.le_x_delta.text() or 0), - 'y_start': float(self.le_y_start_coord.text() or 0), - 'y_delta': float(self.le_y_delta.text() or 0), - 'num_scans': int(self.cb_num_scans.currentText() or 1), - 'row_spacing': float(self.cb_row_spacing.currentText() or 0.1), - 'file_suffix': self.le_file_suffix.text(), - 'pd_trig_voltage': float(self.oscope_dialog.le_set_pd_trig_voltage.text() or 0), - 'sample_min_bias_voltage': float(self.oscope_dialog.le_set_sample_thresh_voltage.text() or 0), - 'trigger_voltage': float(self.oscope_dialog.le_set_trigger_voltage.text() or 0), - 'visa_address': self.oscope_dialog.le_oscope_visa_address.text(), - 'phototrigger_channel': self.oscope_dialog.cb_set_trig_channel.currentText(), - 'bias_a_channel': self.oscope_dialog.cb_set_bias_a_ch.currentText(), - 'bias_b_channel': self.oscope_dialog.cb_set_bias_b_ch.currentText(), - 'rf_saw_channel': self.oscope_dialog.cb_set_saw_channel.currentText() - } - except ValueError: - params = { - 'x_start': 0, 'x_delta': 0, 'y_start': 0, 'y_delta': 0, - 'num_scans': 1, 'row_spacing': 0.1, 'file_suffix': '', - 'pd_trig_voltage': 0, 'sample_min_bias_voltage': 0, 'trigger_voltage': 0, - 'visa_address': '', 'phototrigger_channel': 'CH1', 'bias_a_channel': 'CH1', - 'bias_b_channel': 'CH1', 'rf_saw_channel': 'CH1' - } - - return params - - def _recalculate_scan_parameters(self): - """Recalculate and update scan statistics""" - params = self._collect_scan_parameters() - - # Calculate points per row (stub calculation) - if params['x_delta'] > 0: - points_per_row = int(abs(params['x_start']) / params['x_delta']) - else: - points_per_row = 0 - - # Calculate rows per scan (stub calculation) - if params['row_spacing'] > 0 and params['y_delta'] > 0: - rows_per_scan = int(abs(params['y_delta']) / params['row_spacing']) - else: - rows_per_scan = 0 - - # Calculate totals - scans_in_set = params['num_scans'] - total_records = points_per_row * rows_per_scan * scans_in_set - total_points = total_records - - # Estimate file size (1KB per point as example) - estimated_size_gb = (total_points * 1024) / (1024 ** 3) - - # Update labels - self.l_scan_ppr.setText(str(points_per_row)) - self.l_scan_rps.setText(str(rows_per_scan)) - self.l_scan_sis.setText(str(scans_in_set)) - self.l_scan_total_records.setText(str(total_records)) - self.l_scan_total_points.setText(str(total_points)) - self.l_scan_estimated_size.setText(f"{estimated_size_gb:.2f}GB") - - # Calculate angle spacing - if scans_in_set > 1: - angle_spacing = 360.0 / scans_in_set - else: - angle_spacing = 0 - self.l_scan_angle_spacing.setText(f"{angle_spacing:.2f}°") - - # ==================== Progress Update Methods ==================== - - def update_total_progress(self, current, total): - """ - Update total scan progress - Called from scan control logic to update progress bar - """ - if self.scan_active_dialog: - self.scan_active_dialog.update_total_progress(current, total) - - def update_current_scan_progress(self, current, total): - """ - Update current scan progress - Called from scan control logic to update progress bar - """ - if self.scan_active_dialog: - self.scan_active_dialog.update_current_scan_progress(current, total) - - def update_scan_status(self, scan_num, total_scans, row_num, total_rows, time_remaining): - """ - Update scan status information - Called from scan control logic - """ - if self.scan_active_dialog: - self.scan_active_dialog.update_status( - scan_num, total_scans, row_num, total_rows, time_remaining - ) \ No newline at end of file diff --git a/nuescan/nuescan_genesis_dialog.ui b/nuescan/nuescan_genesis_dialog.ui deleted file mode 100644 index bb279f4..0000000 --- a/nuescan/nuescan_genesis_dialog.ui +++ /dev/null @@ -1,78 +0,0 @@ - - - Dialog - - - - 0 - 0 - 400 - 168 - - - - Dialog - - - - - - - - Scanning Power [mW]: - - - - - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - - buttonBox - accepted() - Dialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - Dialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/nuescan/nuescan_helios_dialog.ui b/nuescan/nuescan_helios_dialog.ui deleted file mode 100644 index 5568b92..0000000 --- a/nuescan/nuescan_helios_dialog.ui +++ /dev/null @@ -1,107 +0,0 @@ - - - Dialog - - - - 0 - 0 - 400 - 173 - - - - Dialog - - - - - - - - - - - - - - Helios Frequency [Hz]: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Laser Current [mA]: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Helios COM Port: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - - buttonBox - accepted() - Dialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - Dialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/nuescan/nuescan_mainwindow.ui b/nuescan/nuescan_mainwindow.ui deleted file mode 100644 index 5dbef6d..0000000 --- a/nuescan/nuescan_mainwindow.ui +++ /dev/null @@ -1,484 +0,0 @@ - - - nueScanWindow - - - - 0 - 0 - 830 - 681 - - - - MainWindow - - - - - - - - - ThorLABS MLS Stage Serial: - - - - - - - - - - Connect - - - - - - - Status - - - - - - - T3R COM Port: - - - - - - - - - - Refresh Serial Devices - - - - - - - Connect - - - - - - - Qt::Horizontal - - - - - - - - 14 - - - - Scan Details and Settings: - - - Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing - - - - - - - Coordinate and Spacing Settings: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - X-Begin [mm] - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - - - X-Delta [mm] - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - - - Y-Begin [mm] - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - - - Y-Delta [mm] - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - - - Row -Spacing [mm]: - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - - - Angular Settings: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - # of Scans: - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - - - Equivalent -Angular Spacing - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - 0 - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - File Suffix: - - - Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing - - - - - - - - - - Qt::Horizontal - - - - - - - - 14 - - - - Timing and Size Information: - - - Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing - - - - - - - Points Per Row - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - 0 - - - Qt::AlignCenter - - - - - - - Rows Per Scan - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - 0 - - - Qt::AlignCenter - - - - - - - Scans In Set - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - 0 - - - Qt::AlignCenter - - - - - - - Total Records - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - 0 - - - Qt::AlignCenter - - - - - - - Total Points -Captured - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - 0 - - - Qt::AlignCenter - - - - - - - Current Size -On Disk - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - 0GB - - - Qt::AlignCenter - - - - - - - Qt::Horizontal - - - - - - - - 20 - - - - Begin Scan - - - - - - - - - - - 0 - 0 - 830 - 30 - - - - - File - - - - - - View - - - - - - - - - - - - - - - - Show Oscope Settings - - - - - Digital IO State - - - - - MLS203 Information - - - - - Transfer System Editor - - - - - Helios Settings - - - - - Genesis Settings - - - - - - diff --git a/nuescan/nuescan_oscope_dialog.ui b/nuescan/nuescan_oscope_dialog.ui deleted file mode 100644 index 34a29e2..0000000 --- a/nuescan/nuescan_oscope_dialog.ui +++ /dev/null @@ -1,170 +0,0 @@ - - - OscopeDialog - - - - 0 - 0 - 466 - 358 - - - - Oscilloscope Settings - - - - - - - - - 16 - - - - Oscilloscope Settings - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing - - - - - - - Phototrigger -Channel - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter - - - - - - - - - - Bias A -Channel - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter - - - - - - - - - - Bias B -Channel - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter - - - - - - - - - - RF/SAW -Channel - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter - - - - - - - - - - PD Trigger -Voltage [V]: - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter - - - - - - - - - - Trigger -Voltage [V]: - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter - - - - - - - - - - Sample -Min Bias [V]: - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignHCenter - - - - - - - - - - VISA Address: - - - - - - - - - - Test -Connect - - - - - - - Save - - - - - - - Cancel - - - - - - - - - - diff --git a/nuescan/nuescan_scan_active_dialog.ui b/nuescan/nuescan_scan_active_dialog.ui deleted file mode 100644 index 3d9ee3a..0000000 --- a/nuescan/nuescan_scan_active_dialog.ui +++ /dev/null @@ -1,275 +0,0 @@ - - - Dialog - - - - 0 - 0 - 858 - 298 - - - - Dialog - - - - - - - - - 24 - true - - - - SCANNING... - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - Cancel Scan - - - - - - - - 12 - - - - of - - - Qt::AlignCenter - - - - - - - 24 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 16 - - - - Scan - - - - - - - - 16 - - - - 09 - - - Qt::AlignCenter - - - - - - - - 16 - - - - 000 - - - Qt::AlignCenter - - - - - - - - 16 - - - - 000 - - - Qt::AlignCenter - - - - - - - 24 - - - - - - - - 16 - - - - 01 - - - Qt::AlignCenter - - - - - - - - 12 - - - - of - - - Qt::AlignCenter - - - - - - - - 16 - - - - Row - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - 16 - false - - - - 00:00:00 remaining.... - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - diff --git a/nuescan/nuescan_status_dialog.ui b/nuescan/nuescan_status_dialog.ui deleted file mode 100644 index bf9ce7e..0000000 --- a/nuescan/nuescan_status_dialog.ui +++ /dev/null @@ -1,473 +0,0 @@ - - - StatusDialog - - - - 0 - 0 - 835 - 260 - - - - Status Indicators - - - - - - - - Stage Status Information: - - - Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing - - - - - - - isConnected? - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - isXHomed? - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - isYHomed? - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - isReady? - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - isScanning? - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - T3R-SL Status Information: - - - Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing - - - - - - - isConnected? - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - isHomed? - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - isReady? - - - Qt::AlignBottom|Qt::AlignHCenter - - - - - - - - Sans Serif - 14 - false - false - - - - font: 14pt "Sans Serif"; - - - Yes - - - Qt::AlignCenter - - - - - - - Microscope Status Information: - - - Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - - 14 - - - - Yes - - - Qt::AlignCenter - - - - - - - Transfer System Information: - - - Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing - - - - - - - Outputs To -Robo-met.3D - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 14 - - - - Low (0) - - - Qt::AlignCenter - - - - - - - - 14 - - - - Low (0) - - - Qt::AlignCenter - - - - - - - - 14 - - - - Low (0) - - - Qt::AlignCenter - - - - - - - - 14 - - - - Low (0) - - - Qt::AlignCenter - - - - - - - Inputs from -Robo-met.3D - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 14 - - - - Low (0) - - - Qt::AlignCenter - - - - - - - - 14 - - - - Low (0) - - - Qt::AlignCenter - - - - - - - - 14 - - - - Low (0) - - - Qt::AlignCenter - - - - - - - - 14 - - - - Low (0) - - - Qt::AlignCenter - - - - - - - - - - diff --git a/nuescan/requirements.txt b/nuescan/requirements.txt deleted file mode 100644 index a2c41b8..0000000 --- a/nuescan/requirements.txt +++ /dev/null @@ -1,19 +0,0 @@ -# nueScan - SRAS Scan Planning and Control Software -# Python Dependencies - -# GUI Framework -PyQt6>=6.4.0 - -# Serial Communication (for hardware interfaces) -pyserial>=3.5 - -# VISA instrument control (for oscilloscope) -pyvisa>=1.13.0 -pyvisa-py>=0.7.0 - -# Optional: For enhanced serial port detection -# pyserial-asyncio>=0.6 - -# Development dependencies (optional) -# pytest>=7.0.0 -# pytest-qt>=4.0.0 diff --git a/nuescan/stage_test_app.py b/nuescan/stage_test_app.py deleted file mode 100644 index 4615533..0000000 --- a/nuescan/stage_test_app.py +++ /dev/null @@ -1,797 +0,0 @@ -#!/usr/bin/env python3 -""" -ThorLabs Stage Test Application -Qt6-based GUI for testing and verifying the BBD203/MLS stage driver functionality - -Copyright (C) 2025 Thomas Ales -Licensed under GNU General Public License v2.0 -""" - -import sys -import time -from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QGroupBox, QPushButton, QLabel, QLineEdit, QComboBox, QTextEdit, - QSpinBox, QDoubleSpinBox, QCheckBox, QGridLayout, QMessageBox, QTabWidget -) -from PyQt6.QtCore import QTimer, Qt -from PyQt6.QtGui import QFont - -from hardware.thorlabs_stage import ThorLabsStage -from hardware.bbd203_protocol import TriggerMode - - -class StageTestWindow(QMainWindow): - """Main window for stage testing application""" - - def __init__(self): - super().__init__() - self.stage = ThorLabsStage() - self.status_timer = QTimer() - self.status_timer.timeout.connect(self.update_status) - - self.init_ui() - self.refresh_devices() - - def init_ui(self): - """Initialize the user interface""" - self.setWindowTitle("ThorLabs Stage Test Application") - self.setGeometry(100, 100, 900, 700) - - # Central widget - central_widget = QWidget() - self.setCentralWidget(central_widget) - - # Main layout - main_layout = QVBoxLayout(central_widget) - - # Title - title_label = QLabel("ThorLabs BBD203/MLS Stage Driver Test") - title_font = QFont() - title_font.setPointSize(16) - title_font.setBold(True) - title_label.setFont(title_font) - title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - main_layout.addWidget(title_label) - - # Connection section - main_layout.addWidget(self.create_connection_group()) - - # Tabbed interface for different sections - tab_widget = QTabWidget() - - # Control tab - control_widget = QWidget() - control_layout = QVBoxLayout(control_widget) - - controls_layout = QHBoxLayout() - controls_layout.addWidget(self.create_homing_group()) - controls_layout.addWidget(self.create_motion_group()) - control_layout.addLayout(controls_layout) - - control_layout.addWidget(self.create_status_group()) - - tab_widget.addTab(control_widget, "Control") - - # Settings tab - settings_widget = QWidget() - settings_layout = QVBoxLayout(settings_widget) - settings_layout.addWidget(self.create_settings_group()) - tab_widget.addTab(settings_widget, "Settings") - - main_layout.addWidget(tab_widget) - - # Log section - main_layout.addWidget(self.create_log_group()) - - # Start status updates - self.status_timer.start(200) # Update every 200ms - - def create_connection_group(self) -> QGroupBox: - """Create connection control group""" - group = QGroupBox("Connection") - layout = QGridLayout() - - # Device selection - layout.addWidget(QLabel("Device:"), 0, 0) - self.device_combo = QComboBox() - layout.addWidget(self.device_combo, 0, 1, 1, 2) - - self.refresh_btn = QPushButton("Refresh Devices") - self.refresh_btn.clicked.connect(self.refresh_devices) - layout.addWidget(self.refresh_btn, 0, 3) - - # Serial number entry - layout.addWidget(QLabel("Serial Number:"), 1, 0) - self.serial_edit = QLineEdit() - self.serial_edit.setPlaceholderText("e.g., 83123456") - layout.addWidget(self.serial_edit, 1, 1, 1, 2) - - # Baudrate - layout.addWidget(QLabel("Baudrate:"), 2, 0) - self.baudrate_combo = QComboBox() - self.baudrate_combo.addItems(["115200", "9600", "19200", "38400", "57600"]) - self.baudrate_combo.setCurrentText("115200") - layout.addWidget(self.baudrate_combo, 2, 1) - - # Connect/Disconnect buttons - self.connect_btn = QPushButton("Connect") - self.connect_btn.clicked.connect(self.connect_stage) - layout.addWidget(self.connect_btn, 2, 2) - - self.disconnect_btn = QPushButton("Disconnect") - self.disconnect_btn.clicked.connect(self.disconnect_stage) - self.disconnect_btn.setEnabled(False) - layout.addWidget(self.disconnect_btn, 2, 3) - - # Identify button - self.identify_btn = QPushButton("Identify (Flash LEDs)") - self.identify_btn.clicked.connect(self.identify_stage) - self.identify_btn.setEnabled(False) - layout.addWidget(self.identify_btn, 3, 0, 1, 4) - - group.setLayout(layout) - return group - - def create_homing_group(self) -> QGroupBox: - """Create homing control group""" - group = QGroupBox("Homing") - layout = QVBoxLayout() - - # Home all button - self.home_all_btn = QPushButton("Home All Axes") - self.home_all_btn.clicked.connect(self.home_all) - self.home_all_btn.setEnabled(False) - layout.addWidget(self.home_all_btn) - - # Individual axis homing - axis_layout = QHBoxLayout() - - self.home_x_btn = QPushButton("Home X") - self.home_x_btn.clicked.connect(lambda: self.home_axis('X')) - self.home_x_btn.setEnabled(False) - axis_layout.addWidget(self.home_x_btn) - - self.home_y_btn = QPushButton("Home Y") - self.home_y_btn.clicked.connect(lambda: self.home_axis('Y')) - self.home_y_btn.setEnabled(False) - axis_layout.addWidget(self.home_y_btn) - - self.home_z_btn = QPushButton("Home Z") - self.home_z_btn.clicked.connect(lambda: self.home_axis('Z')) - self.home_z_btn.setEnabled(False) - axis_layout.addWidget(self.home_z_btn) - - layout.addLayout(axis_layout) - - group.setLayout(layout) - return group - - def create_motion_group(self) -> QGroupBox: - """Create motion control group""" - group = QGroupBox("Motion Control") - layout = QGridLayout() - - # Absolute move controls - layout.addWidget(QLabel("Absolute Move (mm):"), 0, 0, 1, 3) - - layout.addWidget(QLabel("X:"), 1, 0) - self.abs_x_spin = QDoubleSpinBox() - self.abs_x_spin.setRange(-100, 100) - self.abs_x_spin.setDecimals(3) - self.abs_x_spin.setSingleStep(0.1) - layout.addWidget(self.abs_x_spin, 1, 1) - - layout.addWidget(QLabel("Y:"), 2, 0) - self.abs_y_spin = QDoubleSpinBox() - self.abs_y_spin.setRange(-100, 100) - self.abs_y_spin.setDecimals(3) - self.abs_y_spin.setSingleStep(0.1) - layout.addWidget(self.abs_y_spin, 2, 1) - - layout.addWidget(QLabel("Z:"), 3, 0) - self.abs_z_spin = QDoubleSpinBox() - self.abs_z_spin.setRange(-100, 100) - self.abs_z_spin.setDecimals(3) - self.abs_z_spin.setSingleStep(0.1) - layout.addWidget(self.abs_z_spin, 3, 1) - - self.move_abs_btn = QPushButton("Move Absolute") - self.move_abs_btn.clicked.connect(self.move_absolute) - self.move_abs_btn.setEnabled(False) - layout.addWidget(self.move_abs_btn, 4, 0, 1, 2) - - # Relative move controls - layout.addWidget(QLabel("Relative Move (mm):"), 5, 0, 1, 3) - - layout.addWidget(QLabel("dX:"), 6, 0) - self.rel_x_spin = QDoubleSpinBox() - self.rel_x_spin.setRange(-10, 10) - self.rel_x_spin.setDecimals(3) - self.rel_x_spin.setSingleStep(0.1) - layout.addWidget(self.rel_x_spin, 6, 1) - - layout.addWidget(QLabel("dY:"), 7, 0) - self.rel_y_spin = QDoubleSpinBox() - self.rel_y_spin.setRange(-10, 10) - self.rel_y_spin.setDecimals(3) - self.rel_y_spin.setSingleStep(0.1) - layout.addWidget(self.rel_y_spin, 7, 1) - - layout.addWidget(QLabel("dZ:"), 8, 0) - self.rel_z_spin = QDoubleSpinBox() - self.rel_z_spin.setRange(-10, 10) - self.rel_z_spin.setDecimals(3) - self.rel_z_spin.setSingleStep(0.1) - layout.addWidget(self.rel_z_spin, 8, 1) - - self.move_rel_btn = QPushButton("Move Relative") - self.move_rel_btn.clicked.connect(self.move_relative) - self.move_rel_btn.setEnabled(False) - layout.addWidget(self.move_rel_btn, 9, 0, 1, 2) - - # Stop button - self.stop_btn = QPushButton("STOP ALL") - self.stop_btn.clicked.connect(self.stop_all) - self.stop_btn.setEnabled(False) - self.stop_btn.setStyleSheet("background-color: #ff4444; color: white; font-weight: bold;") - layout.addWidget(self.stop_btn, 10, 0, 1, 2) - - group.setLayout(layout) - return group - - def create_settings_group(self) -> QGroupBox: - """Create settings configuration group""" - group = QGroupBox("Stage Settings") - layout = QVBoxLayout() - - # Velocity settings - vel_group = QGroupBox("Velocity (mm/s)") - vel_layout = QGridLayout() - - vel_layout.addWidget(QLabel("X Axis:"), 0, 0) - self.vel_x_spin = QDoubleSpinBox() - self.vel_x_spin.setRange(0.01, 10.0) - self.vel_x_spin.setDecimals(3) - self.vel_x_spin.setSingleStep(0.1) - self.vel_x_spin.setValue(1.0) - vel_layout.addWidget(self.vel_x_spin, 0, 1) - - vel_layout.addWidget(QLabel("Y Axis:"), 1, 0) - self.vel_y_spin = QDoubleSpinBox() - self.vel_y_spin.setRange(0.01, 10.0) - self.vel_y_spin.setDecimals(3) - self.vel_y_spin.setSingleStep(0.1) - self.vel_y_spin.setValue(1.0) - vel_layout.addWidget(self.vel_y_spin, 1, 1) - - vel_layout.addWidget(QLabel("Z Axis:"), 2, 0) - self.vel_z_spin = QDoubleSpinBox() - self.vel_z_spin.setRange(0.01, 10.0) - self.vel_z_spin.setDecimals(3) - self.vel_z_spin.setSingleStep(0.1) - self.vel_z_spin.setValue(1.0) - vel_layout.addWidget(self.vel_z_spin, 2, 1) - - self.apply_vel_btn = QPushButton("Apply Velocity") - self.apply_vel_btn.clicked.connect(self.apply_velocity_settings) - self.apply_vel_btn.setEnabled(False) - vel_layout.addWidget(self.apply_vel_btn, 3, 0, 1, 2) - - vel_group.setLayout(vel_layout) - layout.addWidget(vel_group) - - # Acceleration settings - accel_group = QGroupBox("Acceleration (mm/s²)") - accel_layout = QGridLayout() - - accel_layout.addWidget(QLabel("X Axis:"), 0, 0) - self.accel_x_spin = QDoubleSpinBox() - self.accel_x_spin.setRange(0.1, 100.0) - self.accel_x_spin.setDecimals(2) - self.accel_x_spin.setSingleStep(1.0) - self.accel_x_spin.setValue(5.0) - accel_layout.addWidget(self.accel_x_spin, 0, 1) - - accel_layout.addWidget(QLabel("Y Axis:"), 1, 0) - self.accel_y_spin = QDoubleSpinBox() - self.accel_y_spin.setRange(0.1, 100.0) - self.accel_y_spin.setDecimals(2) - self.accel_y_spin.setSingleStep(1.0) - self.accel_y_spin.setValue(5.0) - accel_layout.addWidget(self.accel_y_spin, 1, 1) - - accel_layout.addWidget(QLabel("Z Axis:"), 2, 0) - self.accel_z_spin = QDoubleSpinBox() - self.accel_z_spin.setRange(0.1, 100.0) - self.accel_z_spin.setDecimals(2) - self.accel_z_spin.setSingleStep(1.0) - self.accel_z_spin.setValue(5.0) - accel_layout.addWidget(self.accel_z_spin, 2, 1) - - self.apply_accel_btn = QPushButton("Apply Acceleration") - self.apply_accel_btn.clicked.connect(self.apply_acceleration_settings) - self.apply_accel_btn.setEnabled(False) - accel_layout.addWidget(self.apply_accel_btn, 3, 0, 1, 2) - - accel_group.setLayout(accel_layout) - layout.addWidget(accel_group) - - # Trigger settings - trigger_group = QGroupBox("Trigger Configuration") - trigger_layout = QGridLayout() - - trigger_layout.addWidget(QLabel("Axis:"), 0, 0) - self.trigger_axis_combo = QComboBox() - self.trigger_axis_combo.addItems(["X", "Y", "Z"]) - trigger_layout.addWidget(self.trigger_axis_combo, 0, 1) - - trigger_layout.addWidget(QLabel("Mode:"), 1, 0) - self.trigger_mode_combo = QComboBox() - self.trigger_mode_combo.addItems([ - "Disabled", - "In/Out Relative Move", - "In/Out Absolute Move", - "In/Out Home", - "In/Out Stop", - "Out Only", - "Out Position" - ]) - trigger_layout.addWidget(self.trigger_mode_combo, 1, 1) - - trigger_layout.addWidget(QLabel("Polarity:"), 2, 0) - self.trigger_polarity_combo = QComboBox() - self.trigger_polarity_combo.addItems(["Active High", "Active Low"]) - trigger_layout.addWidget(self.trigger_polarity_combo, 2, 1) - - self.apply_trigger_btn = QPushButton("Apply Trigger Settings") - self.apply_trigger_btn.clicked.connect(self.apply_trigger_settings) - self.apply_trigger_btn.setEnabled(False) - trigger_layout.addWidget(self.apply_trigger_btn, 3, 0, 1, 2) - - trigger_group.setLayout(trigger_layout) - layout.addWidget(trigger_group) - - # Save/Load buttons - buttons_layout = QHBoxLayout() - - self.load_settings_btn = QPushButton("Load Settings") - self.load_settings_btn.clicked.connect(self.load_settings) - buttons_layout.addWidget(self.load_settings_btn) - - self.save_settings_btn = QPushButton("Save Settings") - self.save_settings_btn.clicked.connect(self.save_settings) - self.save_settings_btn.setEnabled(False) - buttons_layout.addWidget(self.save_settings_btn) - - layout.addLayout(buttons_layout) - - group.setLayout(layout) - return group - - def create_status_group(self) -> QGroupBox: - """Create status display group""" - group = QGroupBox("Status") - layout = QGridLayout() - - # Connection status - layout.addWidget(QLabel("Connected:"), 0, 0) - self.connected_label = QLabel("No") - self.connected_label.setStyleSheet("font-weight: bold; color: red;") - layout.addWidget(self.connected_label, 0, 1) - - # Homed status - layout.addWidget(QLabel("X Homed:"), 1, 0) - self.x_homed_label = QLabel("No") - layout.addWidget(self.x_homed_label, 1, 1) - - layout.addWidget(QLabel("Y Homed:"), 2, 0) - self.y_homed_label = QLabel("No") - layout.addWidget(self.y_homed_label, 2, 1) - - layout.addWidget(QLabel("Z Homed:"), 3, 0) - self.z_homed_label = QLabel("No") - layout.addWidget(self.z_homed_label, 3, 1) - - # Position - layout.addWidget(QLabel("X Position:"), 1, 2) - self.x_pos_label = QLabel("0.000 mm") - layout.addWidget(self.x_pos_label, 1, 3) - - layout.addWidget(QLabel("Y Position:"), 2, 2) - self.y_pos_label = QLabel("0.000 mm") - layout.addWidget(self.y_pos_label, 2, 3) - - layout.addWidget(QLabel("Z Position:"), 3, 2) - self.z_pos_label = QLabel("0.000 mm") - layout.addWidget(self.z_pos_label, 3, 3) - - # Ready/Moving status - layout.addWidget(QLabel("Stage Ready:"), 4, 0) - self.ready_label = QLabel("No") - layout.addWidget(self.ready_label, 4, 1) - - layout.addWidget(QLabel("Moving:"), 4, 2) - self.moving_label = QLabel("No") - layout.addWidget(self.moving_label, 4, 3) - - group.setLayout(layout) - return group - - def create_log_group(self) -> QGroupBox: - """Create log display group""" - group = QGroupBox("Log") - layout = QVBoxLayout() - - self.log_text = QTextEdit() - self.log_text.setReadOnly(True) - self.log_text.setMaximumHeight(150) - layout.addWidget(self.log_text) - - # Clear log button - clear_btn = QPushButton("Clear Log") - clear_btn.clicked.connect(self.log_text.clear) - layout.addWidget(clear_btn) - - group.setLayout(layout) - return group - - # ==================== Connection Methods ==================== - - def refresh_devices(self): - """Refresh list of available devices""" - self.log("Searching for ThorLabs devices...") - devices = ThorLabsStage.list_devices() - - self.device_combo.clear() - - if devices: - for device in devices: - label = f"{device['serial']} - {device['port']} ({device['description']})" - self.device_combo.addItem(label, device['serial']) - self.log(f"Found: {label}") - - # Auto-fill serial number from first device - if self.device_combo.count() > 0: - self.serial_edit.setText(self.device_combo.currentData()) - else: - self.log("No ThorLabs devices found") - - def connect_stage(self): - """Connect to stage""" - serial = self.serial_edit.text().strip() - if not serial: - self.log("ERROR: Please enter a serial number") - return - - baudrate = int(self.baudrate_combo.currentText()) - - self.log(f"Connecting to device {serial} at {baudrate} baud...") - - if self.stage.connect(serial, baudrate): - self.log("Successfully connected to stage") - self.connected_label.setText("Yes") - self.connected_label.setStyleSheet("font-weight: bold; color: green;") - - # Enable controls - self.connect_btn.setEnabled(False) - self.disconnect_btn.setEnabled(True) - self.identify_btn.setEnabled(True) - self.home_all_btn.setEnabled(True) - self.home_x_btn.setEnabled(True) - self.home_y_btn.setEnabled(True) - self.home_z_btn.setEnabled(True) - self.move_abs_btn.setEnabled(True) - self.move_rel_btn.setEnabled(True) - self.stop_btn.setEnabled(True) - self.apply_vel_btn.setEnabled(True) - self.apply_accel_btn.setEnabled(True) - self.apply_trigger_btn.setEnabled(True) - self.save_settings_btn.setEnabled(True) - - # Load current settings into UI - self.load_settings_to_ui() - else: - self.log("ERROR: Failed to connect to stage") - - def disconnect_stage(self): - """Disconnect from stage""" - self.log("Disconnecting from stage...") - - if self.stage.disconnect(): - self.log("Disconnected successfully") - self.connected_label.setText("No") - self.connected_label.setStyleSheet("font-weight: bold; color: red;") - - # Disable controls - self.connect_btn.setEnabled(True) - self.disconnect_btn.setEnabled(False) - self.identify_btn.setEnabled(False) - self.home_all_btn.setEnabled(False) - self.home_x_btn.setEnabled(False) - self.home_y_btn.setEnabled(False) - self.home_z_btn.setEnabled(False) - self.move_abs_btn.setEnabled(False) - self.move_rel_btn.setEnabled(False) - self.stop_btn.setEnabled(False) - self.apply_vel_btn.setEnabled(False) - self.apply_accel_btn.setEnabled(False) - self.apply_trigger_btn.setEnabled(False) - self.save_settings_btn.setEnabled(False) - else: - self.log("ERROR: Failed to disconnect") - - def identify_stage(self): - """Flash LEDs to identify controller""" - self.log("Flashing LEDs for identification...") - if self.stage.identify(): - self.log("Identification command sent") - else: - self.log("ERROR: Failed to send identify command") - - # ==================== Homing Methods ==================== - - def home_all(self): - """Home all axes""" - self.log("Homing all axes...") - if self.stage.home_all_axes(wait=False): - self.log("Homing started for all axes") - else: - self.log("ERROR: Failed to start homing") - - def home_axis(self, axis: str): - """Home specific axis""" - self.log(f"Homing {axis} axis...") - if self.stage.home_axis(axis, wait=False): - self.log(f"{axis} axis homing started") - else: - self.log(f"ERROR: Failed to home {axis} axis") - - # ==================== Motion Methods ==================== - - def move_absolute(self): - """Move to absolute position""" - x = self.abs_x_spin.value() - y = self.abs_y_spin.value() - z = self.abs_z_spin.value() - - self.log(f"Moving to absolute position: X={x}, Y={y}, Z={z}") - - if self.stage.move_absolute(x=x, y=y, z=z, wait=False): - self.log("Absolute move started") - else: - self.log("ERROR: Failed to start absolute move") - - def move_relative(self): - """Move relative distance""" - dx = self.rel_x_spin.value() - dy = self.rel_y_spin.value() - dz = self.rel_z_spin.value() - - self.log(f"Moving relative: dX={dx}, dY={dy}, dZ={dz}") - - if self.stage.move_relative(dx=dx, dy=dy, dz=dz, wait=False): - self.log("Relative move started") - else: - self.log("ERROR: Failed to start relative move") - - def stop_all(self): - """Stop all motion""" - self.log("STOPPING ALL MOTION") - if self.stage.stop_all(immediate=True): - self.log("Stop command sent") - else: - self.log("ERROR: Failed to send stop command") - - # ==================== Status Update ==================== - - def update_status(self): - """Update status display""" - if not self.stage.is_connected(): - return - - try: - # Get status - status = self.stage.get_status() - position = self.stage.get_position() - - # Update homed status - self.x_homed_label.setText("Yes" if status.get('x_homed') else "No") - self.x_homed_label.setStyleSheet( - "color: green;" if status.get('x_homed') else "color: red;" - ) - - self.y_homed_label.setText("Yes" if status.get('y_homed') else "No") - self.y_homed_label.setStyleSheet( - "color: green;" if status.get('y_homed') else "color: red;" - ) - - self.z_homed_label.setText("Yes" if status.get('z_homed') else "No") - self.z_homed_label.setStyleSheet( - "color: green;" if status.get('z_homed') else "color: red;" - ) - - # Update position - self.x_pos_label.setText(f"{position.get('x', 0.0):.3f} mm") - self.y_pos_label.setText(f"{position.get('y', 0.0):.3f} mm") - self.z_pos_label.setText(f"{position.get('z', 0.0):.3f} mm") - - # Update ready/moving status - self.ready_label.setText("Yes" if status.get('ready') else "No") - self.ready_label.setStyleSheet( - "color: green; font-weight: bold;" if status.get('ready') - else "color: orange;" - ) - - self.moving_label.setText("Yes" if status.get('moving') else "No") - self.moving_label.setStyleSheet( - "color: orange; font-weight: bold;" if status.get('moving') - else "color: green;" - ) - - except Exception as e: - self.log(f"ERROR: Failed to update status: {e}") - - # ==================== Settings Methods ==================== - - def load_settings_to_ui(self): - """Load current settings from stage into UI""" - if not self.stage.is_connected(): - return - - try: - # Get current settings - velocities = self.stage.settings.get_all_velocities() - accelerations = self.stage.settings.get_all_accelerations() - - # Update velocity spinboxes - self.vel_x_spin.setValue(velocities['x_axis']) - self.vel_y_spin.setValue(velocities['y_axis']) - self.vel_z_spin.setValue(velocities['z_axis']) - - # Update acceleration spinboxes - self.accel_x_spin.setValue(accelerations['x_axis']) - self.accel_y_spin.setValue(accelerations['y_axis']) - self.accel_z_spin.setValue(accelerations['z_axis']) - - # Update trigger settings for X axis (default) - trigger_config = self.stage.settings.get_trigger_config('x_axis') - self.trigger_mode_combo.setCurrentIndex(trigger_config.get('mode', 0)) - - polarity = trigger_config.get('polarity', 0x01) - self.trigger_polarity_combo.setCurrentIndex(0 if polarity == 0x01 else 1) - - self.log("Settings loaded into UI") - - except Exception as e: - self.log(f"ERROR: Failed to load settings to UI: {e}") - - def apply_velocity_settings(self): - """Apply velocity settings to stage""" - self.log("Applying velocity settings...") - - x = self.vel_x_spin.value() - y = self.vel_y_spin.value() - z = self.vel_z_spin.value() - - if self.stage.configure_velocity(x=x, y=y, z=z, save=False): - self.log(f"Velocity settings applied: X={x}, Y={y}, Z={z} mm/s") - else: - self.log("ERROR: Failed to apply velocity settings") - - def apply_acceleration_settings(self): - """Apply acceleration settings to stage""" - self.log("Applying acceleration settings...") - - x = self.accel_x_spin.value() - y = self.accel_y_spin.value() - z = self.accel_z_spin.value() - - if self.stage.configure_acceleration(x=x, y=y, z=z, save=False): - self.log(f"Acceleration settings applied: X={x}, Y={y}, Z={z} mm/s²") - else: - self.log("ERROR: Failed to apply acceleration settings") - - def apply_trigger_settings(self): - """Apply trigger settings to stage""" - self.log("Applying trigger settings...") - - axis = self.trigger_axis_combo.currentText() - mode_index = self.trigger_mode_combo.currentIndex() - - # Map mode index to TriggerMode enum - mode_map = { - 0: TriggerMode.DISABLED, - 1: TriggerMode.IN_OUT_RELATIVE_MOVE, - 2: TriggerMode.IN_OUT_ABSOLUTE_MOVE, - 3: TriggerMode.IN_OUT_HOME, - 4: TriggerMode.IN_OUT_STOP, - 5: TriggerMode.OUT_ONLY, - 6: TriggerMode.OUT_POSITION - } - mode = mode_map.get(mode_index, TriggerMode.DISABLED) - - # Get polarity - polarity = 0x01 if self.trigger_polarity_combo.currentIndex() == 0 else 0x02 - - if self.stage.configure_trigger(axis, mode, polarity=polarity, save=False): - mode_name = self.trigger_mode_combo.currentText() - pol_name = self.trigger_polarity_combo.currentText() - self.log(f"Trigger settings applied: {axis} axis, {mode_name}, {pol_name}") - else: - self.log("ERROR: Failed to apply trigger settings") - - def save_settings(self): - """Save current settings to file""" - self.log("Saving settings to file...") - - if self.stage.save_current_settings(): - self.log("Settings saved successfully") - QMessageBox.information(self, "Settings Saved", - "Stage settings have been saved successfully.") - else: - self.log("ERROR: Failed to save settings") - QMessageBox.warning(self, "Save Failed", - "Failed to save stage settings.") - - def load_settings(self): - """Load settings from file""" - self.log("Loading settings from file...") - - if self.stage.reload_settings(): - self.log("Settings loaded successfully") - - # Update UI with loaded settings - if self.stage.is_connected(): - self.load_settings_to_ui() - - # Apply to hardware if connected - if self.stage.is_connected(): - self.stage.apply_startup_settings() - - QMessageBox.information(self, "Settings Loaded", - "Stage settings have been loaded successfully.") - else: - self.log("WARNING: No settings file found, using defaults") - QMessageBox.information(self, "No Settings Found", - "No settings file found. Using default values.") - - # ==================== Logging ==================== - - def log(self, message: str): - """Add message to log""" - timestamp = time.strftime("%H:%M:%S") - self.log_text.append(f"[{timestamp}] {message}") - # Auto-scroll to bottom - scrollbar = self.log_text.verticalScrollBar() - scrollbar.setValue(scrollbar.maximum()) - - def closeEvent(self, event): - """Handle window close event""" - if self.stage.is_connected(): - reply = QMessageBox.question( - self, 'Disconnect Stage', - 'Stage is still connected. Disconnect before closing?', - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes - ) - - if reply == QMessageBox.StandardButton.Yes: - self.stage.disconnect() - event.accept() - else: - event.ignore() - else: - event.accept() - - -def main(): - """Main application entry point""" - app = QApplication(sys.argv) - window = StageTestWindow() - window.show() - sys.exit(app.exec()) - - -if __name__ == "__main__": - main() diff --git a/pybbd202/LICENSE b/pybbd202/LICENSE deleted file mode 100644 index 6fbfc92..0000000 --- a/pybbd202/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Thomas K Ales - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/pybbd202/README.md b/pybbd202/README.md deleted file mode 100644 index e7f2941..0000000 --- a/pybbd202/README.md +++ /dev/null @@ -1,668 +0,0 @@ -# BBD202/203 Motion Controller Library - -Python library for controlling Thorlabs BBD202/BBD203 motion controllers via FTDI interface using the APT protocol. - -## Features - -- Full control of X and Y axes -- Absolute and relative positioning -- Configurable velocity and acceleration -- Automatic position tracking -- Status monitoring with convenient properties -- Thread-safe operation -- Context manager support for automatic cleanup - -## Requirements -The FTDI D2XX driver is required for this code to work correctly. You will need to disable linux's -ftdi_sio module in order to use it. I am not sure why, and I have no intent of diagnosing it. - -```bash -pip install pyftdi -``` - -## Quick Start - -```python -from bbd203_controller import MotionController - -# Connect using context manager (automatic cleanup) -with MotionController() as mc: - # Print hardware information - print(f"Model: {mc.get_model()}") - print(f"Serial: {mc.get_serial_number()}") - print(f"Firmware: {mc.get_firmware_version()}") - - # Enable and home X-axis - mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True) - mc.home_x_axis(timeout=20.0) - - # Move to absolute position - mc.set_velocity_params(mc.DEST_X_AXIS, - min_velocity=0.0, - acceleration=100.0, - max_velocity=50.0) - mc.set_move_abs_params(mc.DEST_X_AXIS, absolute_position=25.0) - result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - - print(f"Final position: {result['position']:.3f} mm") -``` - -## Connection Management - -### Basic Connection - -```python -from bbd203_controller import MotionController - -# Manual connection -mc = MotionController() -mc.connect() - -# Use the controller... - -mc.disconnect() -``` - -### Using Context Manager (Recommended) - -```python -# Automatic connection and cleanup -with MotionController() as mc: - # Use the controller... - pass # Automatically disconnects when exiting context -``` - -### Custom FTDI URL - -```python -mc = MotionController(url='ftdi://0x0403:0xfaf0/1', baudrate=115200) -``` - -## Axis Control - -### Enabling Axes - -```python -# Enable X-axis -mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True) - -# Enable Y-axis -mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True) - -# Check if enabled -is_enabled = mc.get_channel_enable_state(mc.DEST_X_AXIS) -``` - -### Homing - -```python -# Home X-axis (blocks until complete) -if mc.home_x_axis(timeout=20.0): - print(f"X-axis homed at position: {mc.position_x} mm") -else: - print("Homing timed out") - -# Home Y-axis -mc.home_y_axis(timeout=20.0) -``` - -After homing, the position automatically resets to 0 mm. - -## Motion Control - -### Setting Velocity Parameters - -```python -# Set velocity parameters for X-axis -mc.set_velocity_params( - mc.DEST_X_AXIS, - min_velocity=0.0, # mm/s - acceleration=100.0, # mm/s² - max_velocity=50.0 # mm/s -) - -# Get current velocity parameters -params = mc.get_velocity_params(mc.DEST_X_AXIS) -print(f"Max velocity: {params['max_velocity']:.2f} mm/s") -print(f"Acceleration: {params['acceleration']:.2f} mm/s²") -``` - -### Setting Acceleration Only - -```python -# Change just the acceleration, preserving velocity settings -mc.set_acceleration(mc.DEST_X_AXIS, 75.0) - -# Get just the acceleration value -accel = mc.get_acceleration(mc.DEST_X_AXIS) -``` - -### Absolute Moves - -```python -# Move to absolute position -mc.set_move_abs_params(mc.DEST_X_AXIS, absolute_position=30.0) -result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - -if result: - print(f"Moved to: {result['position']:.3f} mm") - print(f"Status: 0x{result['status_bits']:08X}") -``` - -### Relative Moves - -```python -# Move relative to current position -mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=5.0) -result = mc.move_relative(mc.DEST_X_AXIS, timeout=30.0) - -# Move backwards -mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=-2.5) -mc.move_relative(mc.DEST_X_AXIS, timeout=30.0) -``` - -### Stopping Motion - -```python -# Controlled stop (gradual deceleration) -mc.stop_x_axis(stop_mode=mc.StopMode.CONTROLLED, wait_for_stopped=True) - -# Immediate stop -mc.stop_x_axis(stop_mode=mc.StopMode.IMMEDIATE, wait_for_stopped=True) - -# Stop both axes simultaneously -results = mc.stop_all_axes(stop_mode=mc.StopMode.CONTROLLED) -``` - -## Position Tracking - -### Reading Current Position - -```python -# Query position from controller (blocking) -position = mc.get_position(mc.DEST_X_AXIS, timeout=5.0) -print(f"X position: {position:.3f} mm") - -# Access cached position (non-blocking) -x_pos = mc.position_x -y_pos = mc.position_y - -# Get encoder counts (raw values) -x_counts = mc.encoder_count_x -``` - -## Status Monitoring - -### Using Status Properties - -```python -# Check various status flags -print(f"X-axis enabled: {mc.is_enabled_x}") -print(f"X-axis homed: {mc.is_homed_x}") -print(f"X-axis in motion: {mc.is_in_motion_x}") -print(f"X-axis settled: {mc.is_settled_x}") -print(f"X-axis has errors: {mc.has_errors_x}") -print(f"Power OK: {mc.power_ok_x}") -``` - -### Decoding Status Bits - -```python -result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - -if result: - status_bits = result['status_bits'] - - # Get human-readable description - description = MotionController.get_status_description(status_bits) - print(description) - - # Check for errors - if MotionController.has_errors(status_bits): - print("ERROR: Motion completed with errors!") - - # Check motion state - if MotionController.is_settled(status_bits): - print("Stage is settled at target position") -``` - -### Available Status Checks - -- `is_enabled_x` / `is_enabled_y` - Motor output enabled -- `is_homed_x` / `is_homed_y` - Axis has been homed -- `is_homing_x` / `is_homing_y` - Currently homing -- `is_in_motion_x` / `is_in_motion_y` - Currently moving -- `is_settled_x` / `is_settled_y` - Settled at target -- `is_tracking_x` / `is_tracking_y` - Within tracking window -- `is_connected_x` / `is_connected_y` - Motor recognized -- `has_errors_x` / `has_errors_y` - Any error condition -- `power_ok_x` / `power_ok_y` - Power supply OK -- `is_active_x` / `is_active_y` - Executing motion command -- `at_cw_limit_x` / `at_cw_limit_y` - At clockwise limit -- `at_ccw_limit_x` / `at_ccw_limit_y` - At counter-clockwise limit - -## Multi-Axis Operations - -### Simultaneous Moves (Using Threading) - -```python -import threading - -def move_x(): - mc.set_move_abs_params(mc.DEST_X_AXIS, 50.0) - mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - -def move_y(): - mc.set_move_abs_params(mc.DEST_Y_AXIS, 30.0) - mc.move_absolute(mc.DEST_Y_AXIS, timeout=30.0) - -# Start both moves in parallel -x_thread = threading.Thread(target=move_x) -y_thread = threading.Thread(target=move_y) - -x_thread.start() -y_thread.start() - -# Wait for both to complete -x_thread.join() -y_thread.join() - -print(f"Final position: ({mc.position_x:.2f}, {mc.position_y:.2f}) mm") -``` - -## Hardware Information - -```python -with MotionController() as mc: - # Individual fields - print(f"Serial Number: {mc.get_serial_number()}") - print(f"Model: {mc.get_model()}") - print(f"Firmware: {mc.get_firmware_version()}") - print(f"Hardware Version: {mc.get_hw_version()}") - print(f"Number of Channels: {mc.get_num_channels()}") - - # All info at once - info = mc.get_hw_info() - for key, value in info.items(): - print(f"{key}: {value}") -``` - -## Complete Examples - -### Example 1: Simple Linear Move - -```python -from bbd203_controller import MotionController -import time - -with MotionController() as mc: - # Enable and home X-axis - mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True) - time.sleep(0.5) - - print("Homing X-axis...") - mc.home_x_axis(timeout=20.0) - print(f"Homed at {mc.position_x} mm") - - # Set velocity for smooth motion - mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 50.0, 25.0) - - # Move to 40mm - print("Moving to 40mm...") - mc.set_move_abs_params(mc.DEST_X_AXIS, 40.0) - result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - - if result and not mc.has_errors_x: - print(f"Successfully moved to {result['position']:.3f} mm") - else: - print("Move failed or has errors") -``` - -### Example 2: Square Pattern with Two Axes - -```python -from bbd203_controller import MotionController -import threading -import time - -def move_to_position(mc, x, y, label): - """Move to (x, y) with both axes moving simultaneously.""" - print(f"Moving to {label}: ({x}, {y}) mm") - - # Set parameters for both axes - mc.set_move_abs_params(mc.DEST_X_AXIS, x) - mc.set_move_abs_params(mc.DEST_Y_AXIS, y) - time.sleep(0.1) - - # Execute moves in parallel - results = [None, None] - - def move_x(): - results[0] = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - - def move_y(): - results[1] = mc.move_absolute(mc.DEST_Y_AXIS, timeout=30.0) - - x_thread = threading.Thread(target=move_x) - y_thread = threading.Thread(target=move_y) - - x_thread.start() - y_thread.start() - x_thread.join() - y_thread.join() - - if results[0] and results[1]: - print(f" Reached ({results[0]['position']:.2f}, {results[1]['position']:.2f}) mm") - return True - return False - -with MotionController() as mc: - # Enable both axes - mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True) - mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True) - time.sleep(0.5) - - # Home both axes - print("Homing axes...") - mc.home_x_axis(timeout=20.0) - mc.home_y_axis(timeout=20.0) - - # Set velocity for both axes - velocity = 50.0 - acceleration = 100.0 - mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, acceleration, velocity) - mc.set_velocity_params(mc.DEST_Y_AXIS, 0.0, acceleration, velocity) - - # Define 20mm square centered at (55, 37.5) - center_x, center_y = 55.0, 37.5 - half_size = 10.0 - - waypoints = [ - (center_x - half_size, center_y - half_size, "Bottom Left"), - (center_x + half_size, center_y - half_size, "Bottom Right"), - (center_x + half_size, center_y + half_size, "Top Right"), - (center_x - half_size, center_y + half_size, "Top Left"), - (center_x, center_y, "Center"), - ] - - # Execute square pattern - for x, y, label in waypoints: - if not move_to_position(mc, x, y, label): - print(f"Failed at {label}") - break - time.sleep(0.5) - - print("Square pattern complete!") -``` - -### Example 3: Velocity Ramping Test - -```python -from bbd203_controller import MotionController -import time - -with MotionController() as mc: - mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True) - time.sleep(0.5) - mc.home_x_axis(timeout=20.0) - - # Test at different velocities - test_velocities = [10.0, 25.0, 50.0, 100.0] - move_distance = 20.0 - - for velocity in test_velocities: - print(f"\n--- Testing at {velocity} mm/s ---") - - # Set velocity parameters - mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 100.0, velocity) - - # Move forward - mc.set_move_abs_params(mc.DEST_X_AXIS, move_distance) - start_time = time.time() - result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - elapsed = time.time() - start_time - - if result: - print(f" Moved {move_distance}mm in {elapsed:.2f}s") - print(f" Average speed: {move_distance/elapsed:.2f} mm/s") - - time.sleep(0.5) - - # Move back to start - mc.set_move_abs_params(mc.DEST_X_AXIS, 0.0) - mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - time.sleep(0.5) -``` - -### Example 4: Position Monitoring During Move - -```python -from bbd203_controller import MotionController -import threading -import time - -with MotionController() as mc: - mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True) - time.sleep(0.5) - mc.home_x_axis(timeout=20.0) - - # Set slow velocity for visible monitoring - mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 50.0, 10.0) - - # Start move in background thread - move_complete = threading.Event() - - def do_move(): - mc.set_move_abs_params(mc.DEST_X_AXIS, 50.0) - mc.move_absolute(mc.DEST_X_AXIS, timeout=60.0) - move_complete.set() - - move_thread = threading.Thread(target=do_move) - move_thread.start() - - # Monitor position while moving - print("Position monitoring:") - while not move_complete.is_set(): - # Request current position - pos = mc.get_position(mc.DEST_X_AXIS, timeout=1.0) - if pos is not None: - print(f" Current position: {pos:.3f} mm, " - f"In motion: {mc.is_in_motion_x}, " - f"Settled: {mc.is_settled_x}") - time.sleep(0.5) - - move_thread.join() - print(f"Move complete! Final position: {mc.position_x:.3f} mm") -``` - -## Constants and Enumerations - -### Axis Destinations - -```python -mc.DEST_CONTROLLER # 0x11 - Controller/motherboard -mc.DEST_X_AXIS # 0x21 - X-axis -mc.DEST_Y_AXIS # 0x22 - Y-axis -``` - -### Stop Modes - -```python -from bbd203_controller import StopMode - -StopMode.IMMEDIATE # 1 - Instant stop -StopMode.CONTROLLED # 2 - Controlled deceleration (default) -``` - -### Jog Modes - -```python -from bbd203_controller import JogMode - -JogMode.CONTINUOUS # 1 - Continuous jogging -JogMode.SINGLE_STEP # 2 - Single step jogging -``` - -### Channel Enable States - -```python -from bbd203_controller import ChannelEnableState - -ChannelEnableState.DISABLED # 0x02 -ChannelEnableState.ENABLED # 0x01 -``` - -## Scaling Factors - -The library handles all unit conversions automatically: - -- **Position**: 20,000 encoder counts per mm -- **Velocity**: 13,421.77 counts per mm/s -- **Acceleration**: 13.744 counts per mm/s² - -## Error Handling - -```python -from bbd203_controller import MotionController - -try: - with MotionController() as mc: - # Invalid axis destination - mc.set_channel_enable_state(0x99, enabled=True) -except ValueError as e: - print(f"ValueError: {e}") - -try: - with MotionController() as mc: - mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True) - mc.home_x_axis(timeout=5.0) # Too short timeout - - if not mc.is_homed_x: - print("Homing failed - axis not homed") -except Exception as e: - print(f"Error: {e}") -``` - -## Advanced Features - -### Message Callbacks - -```python -from bbd203_controller import MotionController, AptMessage, MsgId - -def on_move_stopped(msg: AptMessage): - print(f"Axis stopped unexpectedly!") - print(f"Source: 0x{msg.source:02X}") - -with MotionController() as mc: - # Register callback for stop events - mc.register_callback(MsgId.MOT_MOVE_STOPPED, on_move_stopped) - - # Your motion code here... - - # Unregister when done - mc.unregister_callback(MsgId.MOT_MOVE_STOPPED, on_move_stopped) -``` - -### Direct Message Access - -```python -# Wait for a specific message type -msg = mc.wait_for_message(MsgId.MOT_MOVE_COMPLETED, timeout=30.0) - -# Get next message from queue -msg = mc.get_message(timeout=0.1) - -# Get all queued messages -messages = mc.get_all_messages() -``` - -### Manual Connection Control - -```python -mc = MotionController() - -# Connect with updates disabled -mc.connect(enable_updates=False) - -# Manually start/stop status updates -mc.start_update_messages() -# ... -mc.stop_update_messages() - -mc.disconnect() -``` - -## Tips for Linear Scans - -For performing linear scans at controlled speeds: - -1. **Set velocity parameters** before each scan to ensure consistent motion -2. **Use absolute moves** with pre-calculated waypoints for accuracy -3. **For continuous scanning**: Execute moves sequentially without waiting -4. **For synchronized 2-axis moves**: Use threading (see examples above) -5. **Monitor position** during moves if needed for data acquisition timing - -### Simple 1D Linear Scan - -```python -with MotionController() as mc: - mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True) - mc.home_x_axis(timeout=20.0) - - # Scan parameters - start_pos = 10.0 # mm - end_pos = 90.0 # mm - step_size = 2.0 # mm - scan_speed = 20.0 # mm/s - - # Set velocity for consistent speed - mc.set_velocity_params(mc.DEST_X_AXIS, 0.0, 100.0, scan_speed) - - # Execute scan - position = start_pos - while position <= end_pos: - mc.set_move_abs_params(mc.DEST_X_AXIS, position) - result = mc.move_absolute(mc.DEST_X_AXIS, timeout=30.0) - - if result: - # Acquire data at this position - print(f"Scan point at {result['position']:.3f} mm") - # Your data acquisition code here... - - position += step_size -``` - -## Troubleshooting - -### Controller Not Responding - -If the controller stops responding after many commands: -- The library automatically sends ACK messages every second -- This is handled internally and should not require user intervention - -### Moves Timing Out - -- Increase the `timeout` parameter on move commands -- Check that velocity and acceleration are set appropriately -- Ensure the axis is enabled and homed - -### Position Inaccurate After Homing - -- Position automatically resets to 0 mm after homing completes -- Always wait for homing to complete before issuing move commands -- Check `is_homed_x` / `is_homed_y` properties to verify - -### Unexpected Stops - -- Register a callback for `MsgId.MOT_MOVE_STOPPED` to detect stop events -- Check error flags in status bits -- Ensure no limit switches are being triggered - -## License - -MIT License - -## Author - -Generated for BBD202/BBD203 motion controller control via APT protocol. diff --git a/pymso/.gitignore b/pymso/.gitignore deleted file mode 100644 index 7acb112..0000000 --- a/pymso/.gitignore +++ /dev/null @@ -1 +0,0 @@ -msodev/ diff --git a/pymso/test_acquire_mode.py b/pymso/test_acquire_mode.py deleted file mode 100644 index bec3146..0000000 --- a/pymso/test_acquire_mode.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for acquisition mode get/set functions. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test acquisition mode functions on oscilloscope at 192.168.10.105""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip) - - try: - # Connect to scope - scope.connect() - print(f"✓ Connected to {scope.identify()}\n") - - # Get current acquisition mode - print("Getting current acquisition mode...") - current_mode = scope.get_acquire_mode() - print(f"✓ Current acquisition mode: {current_mode}\n") - - # Test setting different acquisition modes - print("Testing acquisition mode changes:") - test_modes = ['SAMple', 'HIRes', 'AVErage', 'PEAKdetect', 'ENVelope'] - - for mode in test_modes: - print(f"\n Setting mode to: {mode}") - scope.set_acquire_mode(mode) - - # Verify the change - actual_mode = scope.get_acquire_mode() - if actual_mode == mode: - print(f" ✓ Verified: {actual_mode}") - else: - print(f" ✗ Mismatch: expected {mode}, got {actual_mode}") - - # Restore original mode - print(f"\nRestoring original mode: {current_mode}") - scope.set_acquire_mode(current_mode) - print(f"✓ Restored to: {scope.get_acquire_mode()}") - - # Test invalid mode - print("\nTesting invalid mode (should raise ValueError)...") - try: - scope.set_acquire_mode("INVALID") - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - except Exception as e: - print(f"✗ Error: {type(e).__name__}: {e}") - finally: - if scope.is_connected: - scope.disconnect() - print("\n✓ Disconnected from oscilloscope") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_channel.py b/pymso/test_channel.py deleted file mode 100755 index 08c7f40..0000000 --- a/pymso/test_channel.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for channel control functionality. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test channel control functions on oscilloscope at 192.168.10.105""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip) - - try: - # Connect to scope - scope.connect() - print(f"Connected to {scope.identify()}\n") - - # Test with CH1 - test_channel = 1 - print(f"=== Testing Channel {test_channel} ===\n") - - # Query all channel parameters - print("Querying all channel parameters...") - all_params = scope.query_channel(test_channel) - print(f"CH{test_channel} parameters: {all_params[:100]}...\n") - - # Get current settings to restore later - print("Getting current channel settings...") - current_bandwidth = scope.get_channel_bandwidth(test_channel) - current_coupling = scope.get_channel_coupling(test_channel) - current_termination = scope.get_channel_termination(test_channel) - current_scale = scope.get_channel_scale(test_channel) - current_offset = scope.get_channel_offset(test_channel) - current_position = scope.get_channel_position(test_channel) - current_label_name = scope.get_channel_label_name(test_channel) - current_label_color = scope.get_channel_label_color(test_channel) - current_label_font_size = scope.get_channel_label_font_size(test_channel) - current_label_font_type = scope.get_channel_label_font_type(test_channel) - current_label_xpos = scope.get_channel_label_xpos(test_channel) - current_label_ypos = scope.get_channel_label_ypos(test_channel) - - print(f" Current bandwidth: {current_bandwidth}") - print(f" Current coupling: {current_coupling}") - print(f" Current termination: {current_termination} ohms") - print(f" Current scale: {current_scale} V/div") - print(f" Current offset: {current_offset} V") - print(f" Current position: {current_position} divisions") - print(f" Current label name: {current_label_name}") - print(f" Current label color: {current_label_color}") - print(f" Current label font size: {current_label_font_size} pt") - print(f" Current label font type: {current_label_font_type}") - print(f" Current label X position: {current_label_xpos} px") - print(f" Current label Y position: {current_label_ypos} px\n") - - # Test coupling modes - print("Testing coupling modes:") - for coupling in ['DC', 'AC']: - print(f" Setting coupling to {coupling}...") - scope.set_channel_coupling(test_channel, coupling) - actual = scope.get_channel_coupling(test_channel) - print(f" Actual coupling: {actual}") - - # Test termination - print("\nTesting termination settings:") - for term in [50, 1000000]: - print(f" Setting termination to {term} ohms...") - scope.set_channel_termination(test_channel, term) - actual = scope.get_channel_termination(test_channel) - print(f" Actual termination: {actual} ohms") - - # Test vertical scale - print("\nTesting vertical scale:") - test_scales = [0.1, 0.5, 1.0, 2.0] - for scale in test_scales: - print(f" Setting scale to {scale} V/div...") - scope.set_channel_scale(test_channel, scale) - actual = scope.get_channel_scale(test_channel) - print(f" Actual scale: {actual} V/div") - - # Test vertical offset - print("\nTesting vertical offset:") - test_offsets = [0.0, 0.5, -0.5, 1.0] - for offset in test_offsets: - print(f" Setting offset to {offset} V...") - scope.set_channel_offset(test_channel, offset) - actual = scope.get_channel_offset(test_channel) - print(f" Actual offset: {actual} V") - - # Test vertical position - print("\nTesting vertical position:") - test_positions = [0.0, 1.0, -1.0, 2.5] - for position in test_positions: - print(f" Setting position to {position} divisions...") - scope.set_channel_position(test_channel, position) - actual = scope.get_channel_position(test_channel) - print(f" Actual position: {actual} divisions") - - # Test label name - print("\nTesting label name:") - test_names = ["Test Signal", "CH1-Custom", "Probe Input"] - for name in test_names: - print(f" Setting label to '{name}'...") - scope.set_channel_label_name(test_channel, name) - actual = scope.get_channel_label_name(test_channel) - print(f" Actual label: {actual}") - - # Test label color - print("\nTesting label color:") - test_colors = ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"] - for color in test_colors: - print(f" Setting color to {color}...") - scope.set_channel_label_color(test_channel, color) - actual = scope.get_channel_label_color(test_channel) - print(f" Actual color: {actual}") - - # Test label font size - print("\nTesting label font size:") - test_sizes = [10, 12, 14, 16] - for size in test_sizes: - print(f" Setting font size to {size} pt...") - scope.set_channel_label_font_size(test_channel, size) - actual = scope.get_channel_label_font_size(test_channel) - print(f" Actual font size: {actual} pt") - - # Test label font type - print("\nTesting label font type:") - test_fonts = ["Arial", "Helvetica", "Courier"] - for font in test_fonts: - print(f" Setting font to {font}...") - scope.set_channel_label_font_type(test_channel, font) - actual = scope.get_channel_label_font_type(test_channel) - print(f" Actual font: {actual}") - - # Test label position - print("\nTesting label X position:") - test_xpos = [100, 200, 300] - for xpos in test_xpos: - print(f" Setting X position to {xpos} px...") - scope.set_channel_label_xpos(test_channel, xpos) - actual = scope.get_channel_label_xpos(test_channel) - print(f" Actual X position: {actual} px") - - print("\nTesting label Y position:") - test_ypos = [50, 100, 150] - for ypos in test_ypos: - print(f" Setting Y position to {ypos} px...") - scope.set_channel_label_ypos(test_channel, ypos) - actual = scope.get_channel_label_ypos(test_channel) - print(f" Actual Y position: {actual} px") - - # Test using string channel format - print("\nTesting with string channel format ('CH2'):") - print(" Setting CH2 coupling to AC...") - scope.set_channel_coupling('CH2', 'AC') - actual = scope.get_channel_coupling('CH2') - print(f" Actual CH2 coupling: {actual}") - - print(" Setting CH2 scale to 0.5 V/div...") - scope.set_channel_scale('CH2', 0.5) - actual = scope.get_channel_scale('CH2') - print(f" Actual CH2 scale: {actual} V/div") - - # Restore original settings - print(f"\nRestoring original settings for CH{test_channel}...") - scope.set_channel_coupling(test_channel, current_coupling) - scope.set_channel_termination(test_channel, current_termination) - scope.set_channel_scale(test_channel, current_scale) - scope.set_channel_offset(test_channel, current_offset) - scope.set_channel_position(test_channel, current_position) - scope.set_channel_label_name(test_channel, current_label_name) - scope.set_channel_label_color(test_channel, current_label_color) - scope.set_channel_label_font_size(test_channel, current_label_font_size) - scope.set_channel_label_font_type(test_channel, current_label_font_type) - scope.set_channel_label_xpos(test_channel, current_label_xpos) - scope.set_channel_label_ypos(test_channel, current_label_ypos) - print(f" Restored coupling: {scope.get_channel_coupling(test_channel)}") - print(f" Restored termination: {scope.get_channel_termination(test_channel)} ohms") - print(f" Restored scale: {scope.get_channel_scale(test_channel)} V/div") - - # Test invalid inputs - print("\n=== Testing Error Handling ===\n") - - print("Testing invalid channel number (should raise ValueError)...") - try: - scope.get_channel_coupling(5) - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid channel string (should raise ValueError)...") - try: - scope.get_channel_coupling('CH5') - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid coupling (should raise ValueError)...") - try: - scope.set_channel_coupling(1, 'INVALID') - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid termination (should raise ValueError)...") - try: - scope.set_channel_termination(1, 75) - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid scale (should raise ValueError)...") - try: - scope.set_channel_scale(1, -1.0) - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid color format (should raise ValueError)...") - try: - scope.set_channel_label_color(1, "FF0000") # Missing # - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid font size (should raise ValueError)...") - try: - scope.set_channel_label_font_size(1, -5) - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid X position (should raise ValueError)...") - try: - scope.set_channel_label_xpos(1, -10) - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\n=== All tests completed successfully! ===") - - except Exception as e: - print(f"Error: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("\nDisconnected from oscilloscope") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_connection.py b/pymso/test_connection.py deleted file mode 100644 index 933883b..0000000 --- a/pymso/test_connection.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to connect to a Tektronix oscilloscope via raw sockets. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test connection to oscilloscope at 192.168.10.105""" - - scope_ip = "192.168.10.105" - scope_port = 4000 - - print(f"Attempting to connect to oscilloscope at {scope_ip}:{scope_port}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip, port=scope_port, timeout=5.0) - - try: - # Attempt connection - scope.connect() - print(f"✓ Successfully connected to {scope_ip}:{scope_port}") - print(f"✓ Connection status: {scope.is_connected}") - - # Get instrument identification - print("\nQuerying instrument identification...") - idn = scope.identify() - print(f"✓ Instrument ID: {idn}") - - # Test a simple query - print("\nTesting SCPI query...") - response = scope.query("*OPT?") - print(f"✓ Installed options: {response}") - - # Test a write command - print("\nTesting SCPI write command...") - scope.write("*CLS") - print("✓ Cleared status registers") - - except ValueError as e: - print(f"✗ Configuration error: {e}") - except ConnectionError as e: - print(f"✗ Connection failed: {e}") - print("\nTroubleshooting tips:") - print(" - Verify the oscilloscope IP address is correct") - print(" - Check network connectivity (try: ping 192.168.10.105)") - print(" - Ensure the oscilloscope has LXI/socket server enabled") - print(" - Verify port 4000 is correct (check scope network settings)") - except RuntimeError as e: - print(f"✗ Runtime error: {e}") - except Exception as e: - print(f"✗ Unexpected error: {type(e).__name__}: {e}") - finally: - # Always disconnect - if scope.is_connected: - scope.disconnect() - print("\n✓ Disconnected from oscilloscope") - else: - print("\n✗ Not connected") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_fastframe.py b/pymso/test_fastframe.py deleted file mode 100644 index ed5f8f9..0000000 --- a/pymso/test_fastframe.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for FastFrame functionality. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test FastFrame functions on oscilloscope at 192.168.10.105""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip) - - try: - # Connect to scope - scope.connect() - print(f"✓ Connected to {scope.identify()}\n") - - # Get current FastFrame state - print("Getting current FastFrame state...") - current_state = scope.get_fastframe_state() - print(f"✓ Current FastFrame state: {current_state} ({'active' if current_state else 'off'})\n") - - # Get current frame count - print("Getting current frame count...") - current_count = scope.get_fastframe_count() - print(f"✓ Current frame count: {current_count}\n") - - # Test enabling FastFrame - print("Enabling FastFrame...") - scope.set_fastframe_state(1) - state = scope.get_fastframe_state() - print(f"✓ FastFrame state: {state} ({'active' if state else 'off'})\n") - - # Test setting frame count - print("Setting frame count to 100...") - scope.set_fastframe_count(100) - count = scope.get_fastframe_count() - print(f"✓ Frame count: {count}\n") - - # Test using boolean for state - print("Testing boolean state (True)...") - scope.set_fastframe_state(True) - state = scope.get_fastframe_state() - print(f"✓ FastFrame state: {state} ({'active' if state else 'off'})\n") - - print("Testing boolean state (False)...") - scope.set_fastframe_state(False) - state = scope.get_fastframe_state() - print(f"✓ FastFrame state: {state} ({'active' if state else 'off'})\n") - - # Test different frame counts - print("Testing different frame counts:") - test_counts = [10, 50, 200, 500] - for test_count in test_counts: - print(f" Setting count to {test_count}...") - scope.set_fastframe_count(test_count) - actual_count = scope.get_fastframe_count() - if actual_count == test_count: - print(f" ✓ Verified: {actual_count}") - else: - print(f" ✗ Mismatch: expected {test_count}, got {actual_count}") - - # Restore original settings - print(f"\nRestoring original settings...") - scope.set_fastframe_state(current_state) - scope.set_fastframe_count(current_count) - print(f"✓ Restored FastFrame state: {scope.get_fastframe_state()}") - print(f"✓ Restored frame count: {scope.get_fastframe_count()}") - - # Test invalid inputs - print("\nTesting invalid state (should raise ValueError)...") - try: - scope.set_fastframe_state(2) - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - print("\nTesting invalid frame count (should raise ValueError)...") - try: - scope.set_fastframe_count(-1) - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - except Exception as e: - print(f"✗ Error: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("\n✓ Disconnected from oscilloscope") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_fastframe_acquisition.py b/pymso/test_fastframe_acquisition.py deleted file mode 100755 index cca7262..0000000 --- a/pymso/test_fastframe_acquisition.py +++ /dev/null @@ -1,292 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for FastFrame waveform acquisition. - -Tests acquiring 1000 FastFrame records from a 1MHz square wave on CH1. -Signal: 1MHz square wave, +250mV to -250mV -Records: 1000 frames, 2500 points each -""" - -import time -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test FastFrame acquisition with 1MHz square wave on CH1""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=20.0) - - try: - # Connect to scope - scope.connect() - print(f"Connected to {scope.identify()}\n") - - print("=== Configuring Oscilloscope ===\n") - - # Configure CH1 for the signal - print("Configuring CH1...") - print(" Setting vertical scale to 100mV/div...") - scope.set_channel_scale(1, 0.1) # 100mV/div for ±250mV signal - print(" Setting vertical offset to 0V...") - scope.set_channel_offset(1, 0.0) - print(" Setting coupling to DC...") - scope.set_channel_coupling(1, 'DC') - print(" Setting termination to 50 ohms...") - scope.set_channel_termination(1, 50) - - # Verify settings - actual_scale = scope.get_channel_scale(1) - actual_offset = scope.get_channel_offset(1) - actual_coupling = scope.get_channel_coupling(1) - actual_term = scope.get_channel_termination(1) - print(f" Verified: {actual_scale}V/div, {actual_offset}V offset, {actual_coupling} coupling, {actual_term}Ω\n") - - # Configure horizontal timebase - # For 1MHz square wave (1µs period), let's capture ~4 cycles (4µs) - # 4µs over 10 divisions = 400ns/div - print("Configuring horizontal timebase...") - print(" Setting time scale to 400ns/div (4µs total for ~4 cycles of 1MHz)...") - scope.set_time_scale(400e-9) # 400ns/div - print(" Setting record length to 2500 points...") - scope.set_record_length(2500) - - # Verify settings - actual_time_scale = scope.get_time_scale() - actual_record_length = scope.get_record_length() - actual_sample_rate = scope.get_sample_rate() - print(f" Verified: {actual_time_scale*1e9:.0f}ns/div, {actual_record_length} points") - print(f" Sample rate: {actual_sample_rate/1e6:.1f} MS/s\n") - - # Configure trigger - print("Configuring trigger...") - print(" Setting trigger source to CH1...") - scope.set_trigger_source('CH1') - print(" Setting trigger level to +100mV...") - scope.set_trigger_level(1, 0.1) # +100mV - print(" Setting trigger slope to rising...") - scope.set_trigger_slope('RISE') - print(" Setting trigger mode to NORMAL...") - scope.set_trigger_mode('NORMAL') - print(" Setting trigger coupling to DC...") - scope.set_trigger_coupling('DC') - - # Verify trigger settings - actual_trigger_source = scope.get_trigger_source() - actual_trigger_level = scope.get_trigger_level(1) - actual_trigger_slope = scope.get_trigger_slope() - actual_trigger_mode = scope.get_trigger_mode() - print(f" Verified: {actual_trigger_source}, {actual_trigger_level}V, {actual_trigger_slope}, {actual_trigger_mode}\n") - - # Configure FastFrame - print("Configuring FastFrame...") - print(" Enabling FastFrame...") - scope.set_fastframe_state(True) - print(" Setting frame count to 1000...") - scope.set_fastframe_count(1000) - - # Verify FastFrame settings - ff_state = scope.get_fastframe_state() - ff_count = scope.get_fastframe_count() - print(f" Verified: FastFrame {'enabled' if ff_state else 'disabled'}, {ff_count} frames\n") - - # Configure waveform transfer - print("Configuring waveform transfer...") - scope.set_data_encoding('RIBinary') - scope.set_wfmoutpre_encoding('BINary') - scope.set_wfmoutpre_byte_count(1) - scope.set_wfmoutpre_byte_order('MSB') - scope.set_data_source('CH1') - - # Verify configuration - actual_encoding = scope.get_data_encoding() - actual_source = scope.get_data_source() - print(f" Verified: {actual_encoding} encoding, source {actual_source}") - print(" Data transfer configured for 8-bit signed binary\n") - - print("=== Acquiring FastFrame Records ===\n") - - # Wait for FastFrame settings to take effect - print("Waiting for FastFrame configuration to settle...") - time.sleep(0.5) - - # Start acquisition - this arms the scope and begins acquiring triggered frames - print("Starting acquisition (arming scope)...") - scope.write("ACQuire:STATE RUN") - print(" Scope is now armed and acquiring triggered events") - - # Wait and poll for acquisition to complete - print(f" Waiting for {ff_count} triggers to be acquired...") - print(" (Polling acquisition state...)") - - # Poll for up to 10 seconds - for i in range(100): - time.sleep(0.1) - state = scope.query("ACQuire:STATE?") - if i % 10 == 0: # Print every second - print(f" Polling... state: {state.strip()}") - # Check if we've acquired enough frames - if i > 20: # After 2 seconds minimum - break - - # Stop acquisition - print(" Stopping acquisition...") - scope.write("ACQuire:STATE STOP") - - # Verify we have frames - time.sleep(0.2) - print("✓ Acquisition stopped, scope should now have all 1000 frames\n") - - # Clear any leftover data in the receive buffer - print("Clearing receive buffer...") - scope.socket.setblocking(False) - try: - while True: - junk = scope.socket.recv(4096) - if not junk: - break - print(f" Cleared {len(junk)} bytes of junk data") - except: - pass - scope.socket.setblocking(True) - print(" Buffer cleared\n") - - # Test transferring a single frame first - print("Testing single frame transfer first...") - try: - scope.set_fastframe_selected(1) - print(" Selected frame 1") - test_curve = scope.transfer_curve() - print(f" ✓ Successfully transferred {len(test_curve)} bytes") - test_waveform = scope.parse_curve_data(test_curve, byte_count=1, signed=True, byte_order='MSB') - print(f" ✓ Parsed {len(test_waveform)} samples") - print(f" First 10 values: {test_waveform[:10]}\n") - except Exception as e: - print(f" ✗ Single frame test failed: {e}") - print(" Cannot proceed with bulk transfer\n") - raise - - # Now transfer all 1000 frames - print(f"Transferring {ff_count} frames from scope...") - print("This may take a while...\n") - - start_time = time.time() - all_waveforms = [] - - for frame_num in range(1, ff_count + 1): - # Show progress every 100 frames - if frame_num % 100 == 0 or frame_num == 1: - elapsed = time.time() - start_time - if frame_num > 1: - rate = frame_num / elapsed - eta = (ff_count - frame_num) / rate - print(f" Frame {frame_num}/{ff_count} - Elapsed: {elapsed:.1f}s - Rate: {rate:.1f} frames/s - ETA: {eta:.1f}s") - else: - print(f" Frame {frame_num}/{ff_count}...") - - # Select this frame - scope.set_fastframe_selected(frame_num) - - # Verify frame selection for first frame - if frame_num == 1: - actual_frame = scope.get_fastframe_selected() - if actual_frame != frame_num: - print(f" Warning: Frame mismatch - requested {frame_num}, got {actual_frame}") - - # Transfer curve data directly with longer timeout - old_timeout = scope.socket.gettimeout() - scope.socket.settimeout(30.0) - - try: - curve_bytes = scope.transfer_curve() - waveform = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True, byte_order='MSB') - all_waveforms.append(waveform) - finally: - scope.socket.settimeout(old_timeout) - - end_time = time.time() - total_time = end_time - start_time - - print(f"\n✓ Acquired all {ff_count} frames in {total_time:.2f} seconds") - print(f" Average rate: {ff_count / total_time:.2f} frames/second") - print(f" Average time per frame: {total_time / ff_count * 1000:.2f} ms\n") - - # Analyze the acquired data - print("=== Waveform Statistics ===\n") - - # Check that all frames have the expected length - frame_lengths = [len(wf) for wf in all_waveforms] - print(f"Frame lengths: {min(frame_lengths)} to {max(frame_lengths)} points") - - # Calculate statistics for first frame - first_frame = all_waveforms[0] - print(f"\nFirst frame (frame 1):") - print(f" Samples: {len(first_frame)}") - print(f" Min ADC value: {min(first_frame)}") - print(f" Max ADC value: {max(first_frame)}") - print(f" Average ADC value: {sum(first_frame) / len(first_frame):.2f}") - print(f" First 20 values: {first_frame[:20]}") - - # Calculate statistics for middle frame - mid_frame_idx = ff_count // 2 - mid_frame = all_waveforms[mid_frame_idx] - print(f"\nMiddle frame (frame {mid_frame_idx + 1}):") - print(f" Samples: {len(mid_frame)}") - print(f" Min ADC value: {min(mid_frame)}") - print(f" Max ADC value: {max(mid_frame)}") - print(f" Average ADC value: {sum(mid_frame) / len(mid_frame):.2f}") - - # Calculate statistics for last frame - last_frame = all_waveforms[-1] - print(f"\nLast frame (frame {ff_count}):") - print(f" Samples: {len(last_frame)}") - print(f" Min ADC value: {min(last_frame)}") - print(f" Max ADC value: {max(last_frame)}") - print(f" Average ADC value: {sum(last_frame) / len(last_frame):.2f}") - - # Calculate overall statistics - all_values = [val for wf in all_waveforms for val in wf] - print(f"\nOverall statistics (all {ff_count} frames, {len(all_values)} total samples):") - print(f" Min ADC value: {min(all_values)}") - print(f" Max ADC value: {max(all_values)}") - print(f" Average ADC value: {sum(all_values) / len(all_values):.2f}") - print(f" Total data transferred: {len(all_values)} bytes") - print(f" Transfer rate: {len(all_values) / total_time / 1024 / 1024:.2f} MB/s") - - # Check for square wave characteristics - print(f"\nSquare wave detection:") - # A square wave should have values clustered around two levels - positive_samples = sum(1 for v in first_frame if v > 0) - negative_samples = sum(1 for v in first_frame if v < 0) - zero_samples = sum(1 for v in first_frame if v == 0) - print(f" Frame 1: {positive_samples} positive, {negative_samples} negative, {zero_samples} zero samples") - - # Estimate duty cycle from first frame - if len(first_frame) > 0: - duty_cycle = (positive_samples / len(first_frame)) * 100 - print(f" Estimated duty cycle: {duty_cycle:.1f}%") - - print("\n=== FastFrame Acquisition Test Completed Successfully! ===") - - except Exception as e: - print(f"\nError: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - # Try to disable FastFrame before disconnecting - try: - print("\nDisabling FastFrame...") - scope.set_fastframe_state(False) - except Exception: - pass - - scope.disconnect() - print("Disconnected from oscilloscope") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_fastframe_packed.py b/pymso/test_fastframe_packed.py deleted file mode 100644 index 4e5e6fa..0000000 --- a/pymso/test_fastframe_packed.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify FastFrame packed transfer hypothesis. - -Tests if all FastFrame records come in a single CURVe? transfer, -similar to WFMv3 file format. -""" - -import time -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test FastFrame packed transfer""" - - scope_ip = "192.168.10.105" - num_frames = 1000 - target_record_length = 5000 - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=20.0) - - try: - scope.connect() - print(f"Connected to {scope.identify()}\n") - - # Configure CH1 for 1MHz square wave ±250mV - print("Configuring CH1...") - scope.set_channel_scale(1, 0.1) # 100mV/div - scope.set_channel_offset(1, 0.0) - scope.set_channel_coupling(1, 'DC') - scope.set_channel_termination(1, 50) - - # Configure horizontal for 5000 point record length - # At 6.25 GS/s: 5000 points / 6.25e9 = 800ns window = 80ns/div - print("Configuring horizontal for 5000 point record length...") - scope.set_record_length(target_record_length) - scope.set_time_scale(80e-9) # 80ns/div = 800ns window at 6.25 GS/s - - # Check actual settings - actual_record_length = scope.get_record_length() - actual_time_scale = scope.get_time_scale() - actual_sample_rate = scope.get_sample_rate() - print(f" Record length: {actual_record_length}") - print(f" Time scale: {actual_time_scale*1e9:.0f} ns/div") - print(f" Sample rate: {actual_sample_rate/1e6:.1f} MS/s") - - # Configure trigger - print("Configuring trigger...") - scope.set_trigger_source('CH1') - scope.set_trigger_level(1, 0.1) - scope.set_trigger_slope('RISE') - scope.set_trigger_mode('NORMAL') - - # Configure FastFrame - print(f"Configuring FastFrame for {num_frames} frames...") - scope.set_fastframe_state(True) - scope.set_fastframe_count(num_frames) - - # Check record length AFTER enabling FastFrame - actual_record_length_ff = scope.get_record_length() - actual_sample_rate_ff = scope.get_sample_rate() - print(f" Record length after FastFrame: {actual_record_length_ff}") - print(f" Sample rate after FastFrame: {actual_sample_rate_ff/1e6:.1f} MS/s") - - ff_state = scope.get_fastframe_state() - ff_count = scope.get_fastframe_count() - print(f" FastFrame: {'enabled' if ff_state else 'disabled'}, {ff_count} frames\n") - - # Configure waveform transfer - print("Configuring waveform transfer...") - scope.set_data_encoding('RIBinary') - scope.set_wfmoutpre_encoding('BINary') - scope.set_wfmoutpre_byte_count(1) - scope.set_wfmoutpre_byte_order('MSB') - scope.set_data_source('CH1') - - # Acquire data - print("\nAcquiring FastFrame data...") - time.sleep(0.5) - scope.write("ACQuire:STATE RUN") - - # Wait for triggers (no polling - just wait) - print(" Waiting for triggers...") - time.sleep(3) - - scope.write("ACQuire:STATE STOP") - time.sleep(0.5) - print(" Acquisition stopped\n") - - # Transfer all frames individually - print(f"\n=== Transferring {num_frames} Frames Individually ===\n") - - all_waveforms = [] - start_time = time.time() - - for frame_num in range(1, num_frames + 1): - # Progress every 100 frames - if frame_num == 1 or frame_num % 100 == 0: - elapsed = time.time() - start_time - if frame_num > 1: - rate = frame_num / elapsed - eta = (num_frames - frame_num) / rate - print(f" Frame {frame_num}/{num_frames} - {elapsed:.1f}s elapsed - {rate:.1f} fps - ETA {eta:.1f}s") - else: - print(f" Frame {frame_num}/{num_frames}...") - - # Select frame and wait for it to take effect - scope.set_fastframe_selected(frame_num) - time.sleep(0.01) # Small delay for command processing - - # Transfer curve data - curve_bytes = scope.transfer_curve() - waveform = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True, byte_order='MSB') - all_waveforms.append(waveform) - - end_time = time.time() - total_time = end_time - start_time - - print(f"\n=== Transfer Complete ===") - print(f"Frames transferred: {len(all_waveforms)}") - print(f"Points per frame: {len(all_waveforms[0])}") - print(f"Total time: {total_time:.2f}s") - print(f"Rate: {num_frames / total_time:.1f} frames/s") - print(f"Total data: {sum(len(wf) for wf in all_waveforms)} samples") - print(f"Throughput: {sum(len(wf) for wf in all_waveforms) / total_time / 1e6:.2f} MS/s") - - # Show statistics for selected frames - print(f"\n=== Frame Statistics ===") - for frame_idx in [0, num_frames//2, num_frames-1]: - wf = all_waveforms[frame_idx] - print(f"\nFrame {frame_idx + 1}:") - print(f" Samples: {len(wf)}") - print(f" Min: {min(wf)}, Max: {max(wf)}, Avg: {sum(wf)/len(wf):.1f}") - print(f" First 10: {wf[:10]}") - - print("\n=== Test Complete ===") - - except Exception as e: - print(f"\nError: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - try: - scope.set_fastframe_state(False) - except: - pass - scope.disconnect() - print("\nDisconnected") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_fastframe_simple.py b/pymso/test_fastframe_simple.py deleted file mode 100644 index a7b6a93..0000000 --- a/pymso/test_fastframe_simple.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple FastFrame test - minimal setup. -""" - -import time -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - scope_ip = "192.168.10.105" - - print(f"Connecting to {scope_ip}...") - scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=20.0) - - try: - scope.connect() - print(f"Connected: {scope.identify()}\n") - - # Configure CH1 (same as failing test) - print("Configuring CH1...") - scope.set_channel_scale(1, 0.1) - scope.set_channel_offset(1, 0.0) - scope.set_channel_coupling(1, 'DC') - scope.set_channel_termination(1, 50) - - # Configure horizontal (same as failing test) - print("Configuring horizontal...") - scope.set_record_length(5000) - scope.set_time_scale(80e-9) - actual_record_length = scope.get_record_length() - actual_time_scale = scope.get_time_scale() - actual_sample_rate = scope.get_sample_rate() - print(f" Record: {actual_record_length}, Time: {actual_time_scale*1e9:.0f}ns/div, Rate: {actual_sample_rate/1e6:.1f}MS/s") - - # Configure trigger (same as failing test) - print("Configuring trigger...") - scope.set_trigger_source('CH1') - scope.set_trigger_level(1, 0.1) - scope.set_trigger_slope('RISE') - scope.set_trigger_mode('NORMAL') - scope.set_trigger_coupling('DC') - - # Enable FastFrame with 1000 frames - print("Enabling FastFrame (1000 frames)...") - scope.set_fastframe_state(True) - scope.set_fastframe_count(1000) - - ff_state = scope.get_fastframe_state() - ff_count = scope.get_fastframe_count() - print(f" State: {ff_state}, Count: {ff_count}") - - # Configure data transfer (same as failing test) - print("Configuring data transfer...") - scope.set_data_encoding('RIBinary') - scope.set_wfmoutpre_encoding('BINary') - scope.set_wfmoutpre_byte_count(1) - scope.set_wfmoutpre_byte_order('MSB') - scope.set_data_source('CH1') - - # Acquire - print("Acquiring...") - scope.write("ACQuire:STATE RUN") - time.sleep(2) # Wait for triggers - scope.write("ACQuire:STATE STOP") - time.sleep(0.5) - - # Check record length - record_len = scope.get_record_length() - print(f"Record length: {record_len}") - - # Test: Send single CURVe? and read ALL frames - print(f"\nTesting: Send one CURVe? and read all {ff_count} frames...") - scope.set_fastframe_selected(1) # Start from frame 1 - scope.write("CURVe?") - - all_waveforms = [] - start_time = time.time() - - for frame_num in range(1, ff_count + 1): - if frame_num == 1 or frame_num % 100 == 0: - elapsed = time.time() - start_time - if frame_num > 1: - rate = frame_num / elapsed - print(f" Frame {frame_num}/{ff_count} - {rate:.1f} fps") - else: - print(f" Frame {frame_num}/{ff_count}...") - - # Read one frame using read_raw (no new CURVe? command) - curve_bytes = scope.read_raw() - waveform = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True) - all_waveforms.append(waveform) - - end_time = time.time() - total_time = end_time - start_time - - print(f"\nTransferred {len(all_waveforms)} frames in {total_time:.2f}s") - print(f"Rate: {ff_count / total_time:.1f} frames/s") - print(f"Points per frame: {len(all_waveforms[0])}") - print(f"First frame: min={min(all_waveforms[0])}, max={max(all_waveforms[0])}") - print(f"Last frame: min={min(all_waveforms[-1])}, max={max(all_waveforms[-1])}") - - except Exception as e: - print(f"\nError: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - try: - scope.set_fastframe_state(False) - print("FastFrame disabled") - except: - pass - scope.disconnect() - print("Disconnected") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_horizontal.py b/pymso/test_horizontal.py deleted file mode 100644 index 1af2f7d..0000000 --- a/pymso/test_horizontal.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for horizontal mode control functionality. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test horizontal mode functions on oscilloscope at 192.168.10.105""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip) - - try: - # Connect to scope - scope.connect() - print(f"✓ Connected to {scope.identify()}\n") - - # Get current horizontal settings - print("Getting current horizontal settings...") - current_record_length = scope.get_record_length() - current_sample_rate = scope.get_sample_rate() - current_time_scale = scope.get_time_scale() - print(f"✓ Current record length: {current_record_length} samples") - print(f"✓ Current sample rate: {current_sample_rate} S/s") - print(f"✓ Current time scale: {current_time_scale} s/div\n") - - # Test setting record length - print("Testing record length changes:") - test_lengths = [1000, 10000, 100000] - for length in test_lengths: - print(f" Setting record length to {length}...") - scope.set_record_length(length) - actual_length = scope.get_record_length() - print(f" ✓ Actual record length: {actual_length}") - - # Test setting sample rate - print("\nTesting sample rate changes:") - test_rates = [1e6, 10e6, 100e6] # 1 MS/s, 10 MS/s, 100 MS/s - for rate in test_rates: - print(f" Setting sample rate to {rate:.0f} S/s...") - scope.set_sample_rate(rate) - actual_rate = scope.get_sample_rate() - print(f" ✓ Actual sample rate: {actual_rate:.0f} S/s") - - # Test setting time scale - print("\nTesting time scale changes:") - test_scales = [1e-6, 10e-6, 100e-6, 1e-3] # 1 µs/div, 10 µs/div, 100 µs/div, 1 ms/div - for scale in test_scales: - print(f" Setting time scale to {scale:.6f} s/div...") - scope.set_time_scale(scale) - actual_scale = scope.get_time_scale() - print(f" ✓ Actual time scale: {actual_scale:.6f} s/div") - - # Restore original settings - print(f"\nRestoring original settings...") - scope.set_record_length(current_record_length) - scope.set_sample_rate(current_sample_rate) - scope.set_time_scale(current_time_scale) - print(f"✓ Restored record length: {scope.get_record_length()}") - print(f"✓ Restored sample rate: {scope.get_sample_rate()}") - print(f"✓ Restored time scale: {scope.get_time_scale()}") - - # Test invalid inputs - print("\nTesting invalid record length (should raise ValueError)...") - try: - scope.set_record_length(-1) - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - print("\nTesting invalid sample rate (should raise ValueError)...") - try: - scope.set_sample_rate(0) - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - print("\nTesting invalid time scale (should raise ValueError)...") - try: - scope.set_time_scale(-1.0) - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - except Exception as e: - print(f"✗ Error: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("\n✓ Disconnected from oscilloscope") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_simple_curve.py b/pymso/test_simple_curve.py deleted file mode 100644 index f3b6ff5..0000000 --- a/pymso/test_simple_curve.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple curve transfer test - no FastFrame. -""" - -import time -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - scope_ip = "192.168.10.105" - - print(f"Connecting to {scope_ip}...") - scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=20.0) - - try: - scope.connect() - print(f"Connected: {scope.identify()}\n") - - # Make sure FastFrame is OFF - print("Disabling FastFrame...") - scope.set_fastframe_state(False) - - # Configure simple acquisition - print("Configuring acquisition...") - scope.set_data_encoding('RIBinary') - scope.set_data_source('CH1') - - # Single acquisition - print("Running single acquisition...") - scope.write("ACQuire:STATE RUN") - time.sleep(0.5) - scope.write("ACQuire:STATE STOP") - time.sleep(0.2) - - # Transfer curve - print("Transferring curve data...") - curve_bytes = scope.transfer_curve() - print(f"Received {len(curve_bytes)} bytes") - - # Parse - waveform = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True) - print(f"Parsed {len(waveform)} samples") - print(f"Min: {min(waveform)}, Max: {max(waveform)}") - print(f"First 10: {waveform[:10]}") - - print("\nSuccess!") - - except Exception as e: - print(f"\nError: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("Disconnected") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_simple_query.py b/pymso/test_simple_query.py deleted file mode 100644 index 7f5e8b6..0000000 --- a/pymso/test_simple_query.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple test to debug query timeout issues. -""" - -from tektronix_base import TektronixOscilloscopeBase -import time - - -def main(): - """Test simple queries with the oscilloscope""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=10.0) - - try: - # Connect - scope.connect() - print("✓ Connected") - - # Wait a moment to ensure connection is stable - time.sleep(0.5) - - # Try a simple query - print("\nSending *IDN? query...") - idn = scope.query("*IDN?") - print(f"✓ Response: {idn}") - - # Try acquisition mode query - print("\nSending ACQuire:MODe? query...") - mode = scope.query("ACQuire:MODe?") - print(f"✓ Current mode: {mode}") - - # Set mode to sample using short form - print("\nSetting mode to SAM (short form)...") - scope.set_acquire_mode("SAM") - - # Verify - mode = scope.get_acquire_mode() - print(f"✓ Mode is now: {mode}") - - except Exception as e: - print(f"✗ Error: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("\n✓ Disconnected") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_single_waveform.py b/pymso/test_single_waveform.py deleted file mode 100755 index b1a764b..0000000 --- a/pymso/test_single_waveform.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple test to verify basic waveform acquisition works. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test single waveform acquisition from CH1""" - - scope_ip = "192.168.10.105" - print(f"Connecting to {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=15.0) - - try: - scope.connect() - print(f"✓ Connected to {scope.identify()}\n") - - # Check current settings - print("Current settings:") - print(f" Data encoding: {scope.get_data_encoding()}") - print(f" Data source: {scope.get_data_source()}") - print(f" Record length: {scope.get_record_length()}") - print(f" FastFrame state: {scope.get_fastframe_state()}\n") - - # Ensure FastFrame is off - if scope.get_fastframe_state(): - print("Disabling FastFrame...") - scope.set_fastframe_state(False) - - # Set data source to CH1 - print("Setting data source to CH1...") - scope.set_data_source('CH1') - - # Try to acquire a single waveform - print("\nAttempting to acquire waveform from CH1...") - print("(This will timeout if the scope isn't responding properly)\n") - - waveform = scope.acquire_waveform('CH1') - - print(f"✓ SUCCESS! Acquired {len(waveform)} samples") - print(f" First 10 values: {waveform[:10]}") - print(f" Min: {min(waveform)}, Max: {max(waveform)}") - print(f" Average: {sum(waveform)/len(waveform):.2f}") - - except Exception as e: - print(f"\n✗ Error: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("\n✓ Disconnected") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_transfer_fastframe.py b/pymso/test_transfer_fastframe.py deleted file mode 100644 index fde3a9a..0000000 --- a/pymso/test_transfer_fastframe.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for transfer_fastframe() method. - -Tests bulk FastFrame transfer with 1MHz square wave on CH1. -Signal: 1MHz square wave, +125mV to -125mV -""" - -import time -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - scope_ip = "192.168.10.105" - num_frames = 1000 - - print(f"Connecting to {scope_ip}...") - scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=30.0) - - try: - scope.connect() - print(f"Connected: {scope.identify()}\n") - - # Configure CH1 - print("Configuring CH1...") - scope.set_channel_scale(1, 0.1) # 100mV/div - scope.set_channel_offset(1, 0.0) - scope.set_channel_coupling(1, 'DC') - scope.set_channel_termination(1, 50) - - # Configure horizontal - print("Configuring horizontal...") - scope.set_record_length(5000) - scope.set_time_scale(80e-9) # 80ns/div - - # Configure trigger - print("Configuring trigger...") - scope.set_trigger_source('CH1') - scope.set_trigger_level(1, 0.05) # 50mV - scope.set_trigger_slope('RISE') - scope.set_trigger_mode('NORMAL') - - # Configure FastFrame - print(f"Configuring FastFrame ({num_frames} frames)...") - scope.set_fastframe_state(True) - scope.set_fastframe_count(num_frames) - - # Verify settings - ff_count = scope.get_fastframe_count() - record_len = scope.get_record_length() - print(f" Frames: {ff_count}, Record length: {record_len}") - - # Configure data transfer - print("Configuring data transfer...") - scope.set_data_encoding('RIBinary') - scope.set_data_source('CH1') - - # Acquire - print("\nAcquiring FastFrame data...") - scope.write("ACQuire:STATE RUN") - time.sleep(2) # Wait for triggers - scope.write("ACQuire:STATE STOP") - time.sleep(0.5) - print(" Acquisition complete") - - # Transfer all frames using the new method - print(f"\nTransferring {ff_count} frames using transfer_fastframe()...") - start_time = time.time() - - waveforms = scope.transfer_fastframe(parse=True, byte_count=1, signed=True) - - end_time = time.time() - total_time = end_time - start_time - - # Results - print(f"\n=== Transfer Complete ===") - print(f"Frames received: {len(waveforms)}") - print(f"Points per frame: {len(waveforms[0])}") - print(f"Total samples: {sum(len(wf) for wf in waveforms)}") - print(f"Transfer time: {total_time:.3f}s") - print(f"Frame rate: {len(waveforms) / total_time:.1f} frames/s") - print(f"Sample rate: {sum(len(wf) for wf in waveforms) / total_time / 1e6:.2f} MS/s") - - # Waveform statistics - print(f"\n=== Waveform Statistics ===") - print(f"Frame 1: min={min(waveforms[0])}, max={max(waveforms[0])}, avg={sum(waveforms[0])/len(waveforms[0]):.1f}") - print(f"Frame {len(waveforms)//2}: min={min(waveforms[len(waveforms)//2])}, max={max(waveforms[len(waveforms)//2])}") - print(f"Frame {len(waveforms)}: min={min(waveforms[-1])}, max={max(waveforms[-1])}") - - # Check for square wave - first_frame = waveforms[0] - positive = sum(1 for v in first_frame if v > 0) - negative = sum(1 for v in first_frame if v < 0) - print(f"\nSquare wave check (frame 1): {positive} positive, {negative} negative samples") - print(f"Duty cycle estimate: {positive / len(first_frame) * 100:.1f}%") - - print("\n=== Success! ===") - - except Exception as e: - print(f"\nError: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - try: - scope.set_fastframe_state(False) - print("\nFastFrame disabled") - except: - pass - scope.disconnect() - print("Disconnected") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_trigger.py b/pymso/test_trigger.py deleted file mode 100644 index 0fc14e5..0000000 --- a/pymso/test_trigger.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for trigger control functionality. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test trigger functions on oscilloscope at 192.168.10.105""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip) - - try: - # Connect to scope - scope.connect() - print(f"✓ Connected to {scope.identify()}\n") - - # Get current trigger settings - print("Getting current trigger settings...") - current_coupling = scope.get_trigger_coupling() - current_slope = scope.get_trigger_slope() - current_source = scope.get_trigger_source() - current_mode = scope.get_trigger_mode() - print(f"✓ Current trigger coupling: {current_coupling}") - print(f"✓ Current trigger slope: {current_slope}") - print(f"✓ Current trigger source: {current_source}") - print(f"✓ Current trigger mode: {current_mode}") - - # Get trigger level for current source - # Extract channel number from source - if current_source.startswith('CH'): - channel = int(current_source[2]) - current_level = scope.get_trigger_level(channel) - print(f"✓ Current trigger level for {current_source}: {current_level} V\n") - else: - current_level = 0.0 - channel = 1 - print(f" (Source is {current_source}, not a channel)\n") - - # Test trigger coupling - print("Testing trigger coupling modes:") - test_couplings = ['DC', 'HFRej', 'LFRej', 'NOISErej'] - for coupling in test_couplings: - print(f" Setting coupling to {coupling}...") - scope.set_trigger_coupling(coupling) - actual_coupling = scope.get_trigger_coupling() - print(f" ✓ Actual coupling: {actual_coupling}") - - # Test trigger slope - print("\nTesting trigger slope modes:") - test_slopes = ['RISe', 'FALL', 'EITher'] - for slope in test_slopes: - print(f" Setting slope to {slope}...") - scope.set_trigger_slope(slope) - actual_slope = scope.get_trigger_slope() - print(f" ✓ Actual slope: {actual_slope}") - - # Test trigger source - print("\nTesting trigger source selection:") - test_sources = ['CH1', 'CH2', 'CH3', 'CH4'] - for source in test_sources: - print(f" Setting source to {source}...") - scope.set_trigger_source(source) - actual_source = scope.get_trigger_source() - print(f" ✓ Actual source: {actual_source}") - - # Test trigger source with integer - print("\nTesting trigger source with integer (2)...") - scope.set_trigger_source(2) - actual_source = scope.get_trigger_source() - print(f"✓ Actual source: {actual_source}") - - # Test trigger level - print("\nTesting trigger level settings:") - test_levels = [0.0, 0.5, 1.0, -0.5, 2.5] - for level in test_levels: - print(f" Setting CH1 trigger level to {level} V...") - scope.set_trigger_level(1, level) - actual_level = scope.get_trigger_level(1) - print(f" ✓ Actual level: {actual_level} V") - - # Test trigger level with channel string - print("\nTesting trigger level with channel string ('CH2')...") - scope.set_trigger_level('CH2', 1.5) - actual_level = scope.get_trigger_level('CH2') - print(f"✓ Actual level: {actual_level} V") - - # Test trigger mode - print("\nTesting trigger modes:") - test_modes = ['AUTO', 'NORMal'] - for mode in test_modes: - print(f" Setting trigger mode to {mode}...") - scope.set_trigger_mode(mode) - actual_mode = scope.get_trigger_mode() - print(f" ✓ Actual mode: {actual_mode}") - - # Restore original settings - print(f"\nRestoring original trigger settings...") - scope.set_trigger_coupling(current_coupling) - scope.set_trigger_slope(current_slope) - scope.set_trigger_source(current_source) - scope.set_trigger_mode(current_mode) - if current_source.startswith('CH'): - scope.set_trigger_level(channel, current_level) - print(f"✓ Restored trigger coupling: {scope.get_trigger_coupling()}") - print(f"✓ Restored trigger slope: {scope.get_trigger_slope()}") - print(f"✓ Restored trigger source: {scope.get_trigger_source()}") - print(f"✓ Restored trigger mode: {scope.get_trigger_mode()}") - - # Test invalid inputs - print("\nTesting invalid coupling (should raise ValueError)...") - try: - scope.set_trigger_coupling("INVALID") - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - print("\nTesting invalid slope (should raise ValueError)...") - try: - scope.set_trigger_slope("INVALID") - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - print("\nTesting invalid source (should raise ValueError)...") - try: - scope.set_trigger_source("CH5") - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - print("\nTesting invalid channel for trigger level (should raise ValueError)...") - try: - scope.set_trigger_level(5, 0.0) - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - print("\nTesting invalid trigger mode (should raise ValueError)...") - try: - scope.set_trigger_mode("INVALID") - print("✗ ERROR: Should have raised ValueError!") - except ValueError as e: - print(f"✓ Correctly raised ValueError: {e}") - - except Exception as e: - print(f"✗ Error: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("\n✓ Disconnected from oscilloscope") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_waveform.py b/pymso/test_waveform.py deleted file mode 100755 index 05c7aac..0000000 --- a/pymso/test_waveform.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for waveform transfer functionality. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Test waveform transfer functions on oscilloscope at 192.168.10.105""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip) - - try: - # Connect to scope - scope.connect() - print(f"Connected to {scope.identify()}\n") - - # Save current settings - print("Saving current waveform transfer settings...") - current_data_encoding = scope.get_data_encoding() - current_data_source = scope.get_data_source() - current_wfmoutpre_encoding = scope.get_wfmoutpre_encoding() - current_byte_count = scope.get_wfmoutpre_byte_count() - current_byte_order = scope.get_wfmoutpre_byte_order() - print(f" Current data encoding: {current_data_encoding}") - print(f" Current data source: {current_data_source}") - print(f" Current WFMOutpre encoding: {current_wfmoutpre_encoding}") - print(f" Current byte count: {current_byte_count}") - print(f" Current byte order: {current_byte_order}\n") - - # Test setting data encoding - print("Testing data encoding settings:") - test_encodings = ['ASCIi', 'RIBinary', 'RPBinary'] - for encoding in test_encodings: - print(f" Setting data encoding to {encoding}...") - scope.set_data_encoding(encoding) - actual = scope.get_data_encoding() - print(f" Actual encoding: {actual}") - - # Test setting data source - print("\nTesting data source selection:") - for source in ['CH1', 'CH2', 'CH3', 'CH4']: - print(f" Setting data source to {source}...") - scope.set_data_source(source) - actual = scope.get_data_source() - print(f" Actual source: {actual}") - - # Test with integer source - print("\nTesting data source with integer (2)...") - scope.set_data_source(2) - actual = scope.get_data_source() - print(f" Actual source: {actual}") - - # Test waveform preamble encoding - print("\nTesting waveform preamble encoding:") - for encoding in ['BINary', 'ASCii']: - print(f" Setting WFMOutpre encoding to {encoding}...") - scope.set_wfmoutpre_encoding(encoding) - actual = scope.get_wfmoutpre_encoding() - print(f" Actual encoding: {actual}") - - # Test byte count - print("\nTesting byte count settings:") - for byte_count in [1, 2]: - print(f" Setting byte count to {byte_count}...") - scope.set_wfmoutpre_byte_count(byte_count) - actual = scope.get_wfmoutpre_byte_count() - print(f" Actual byte count: {actual}") - - # Test byte order - print("\nTesting byte order settings:") - for byte_order in ['MSB', 'LSB']: - print(f" Setting byte order to {byte_order}...") - scope.set_wfmoutpre_byte_order(byte_order) - actual = scope.get_wfmoutpre_byte_order() - print(f" Actual byte order: {actual}") - - # Query complete waveform preamble - print("\nQuerying complete waveform preamble...") - preamble = scope.query_wfmoutpre() - print(f" Preamble (first 100 chars): {preamble[:100]}...") - - # Set up for binary waveform acquisition - print("\nConfiguring for binary waveform transfer:") - print(" Setting data encoding to RIBinary...") - scope.set_data_encoding('RIBinary') - print(" Setting WFMOutpre encoding to BINary...") - scope.set_wfmoutpre_encoding('BINary') - print(" Setting byte count to 1...") - scope.set_wfmoutpre_byte_count(1) - print(" Setting byte order to MSB...") - scope.set_wfmoutpre_byte_order('MSB') - print(" Setting data source to CH1...") - scope.set_data_source('CH1') - print(" Configuration complete") - - # Transfer curve data - print("\nTransferring curve data from CH1...") - curve_data = scope.transfer_curve() - print(f" Received {len(curve_data)} bytes of curve data") - print(f" First 10 bytes (raw): {list(curve_data[:10])}") - - # Parse the curve data - print("\nParsing curve data...") - values = scope.parse_curve_data(curve_data, byte_count=1, signed=True, byte_order='MSB') - print(f" Parsed {len(values)} samples") - print(f" First 10 values: {values[:10]}") - print(f" Min value: {min(values)}") - print(f" Max value: {max(values)}") - print(f" Average value: {sum(values) / len(values):.2f}") - - # Transfer complete waveform (preamble + curve) - print("\nTransferring complete waveform (WAVFrm?)...") - preamble_str, curve_bytes = scope.transfer_waveform() - print(f" Received preamble: {preamble_str[:100]}...") - print(f" Received {len(curve_bytes)} bytes of curve data") - - # Parse this curve data too - waveform_values = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True, byte_order='MSB') - print(f" Parsed {len(waveform_values)} samples from complete waveform") - - # Test high-level acquire_waveform method - print("\nTesting high-level acquire_waveform method:") - print(" Acquiring waveform from CH1...") - waveform = scope.acquire_waveform('CH1') - print(f" Acquired {len(waveform)} samples") - print(f" First 10 values: {waveform[:10]}") - print(f" Min: {min(waveform)}, Max: {max(waveform)}, Avg: {sum(waveform)/len(waveform):.2f}") - - print("\n Acquiring waveform from CH2 (using integer)...") - try: - waveform2 = scope.acquire_waveform(2) - print(f" Acquired {len(waveform2)} samples from CH2") - print(f" First 10 values: {waveform2[:10]}") - except Exception as e: - print(f" Could not acquire from CH2 (channel may not be active): {e}") - - # Test FastFrame support - print("\nTesting FastFrame frame selection:") - - # Check if FastFrame is currently enabled - try: - ff_state = scope.get_fastframe_state() - ff_count = scope.get_fastframe_count() - print(f" Current FastFrame state: {ff_state} ({'active' if ff_state else 'off'})") - print(f" Current frame count: {ff_count}") - except Exception as e: - print(f" Could not query FastFrame state (skipping FastFrame tests): {e}") - ff_state = None - - if ff_state is not None and ff_state: - # FastFrame is active, test frame selection - print(" FastFrame is active, testing frame selection...") - current_frame = scope.get_fastframe_selected() - print(f" Current selected frame: {current_frame}") - - # Try selecting different frames - for frame in [1, min(5, ff_count), ff_count]: - print(f" Selecting frame {frame}...") - scope.set_fastframe_selected(frame) - actual = scope.get_fastframe_selected() - print(f" Selected frame: {actual}") - - # Acquire waveform from this frame - print(f" Acquiring waveform from frame {frame}...") - frame_waveform = scope.acquire_waveform('CH1', frame_number=frame) - print(f" Acquired {len(frame_waveform)} samples") - print(f" First 5 values: {frame_waveform[:5]}") - - # Restore original frame - scope.set_fastframe_selected(current_frame) - elif ff_state is not None: - print(" FastFrame is not active, enabling it temporarily...") - scope.set_fastframe_state(True) - scope.set_fastframe_count(10) - print(" FastFrame enabled with 10 frames") - - # Test frame selection - for frame in [1, 5, 10]: - print(f" Selecting frame {frame}...") - scope.set_fastframe_selected(frame) - actual = scope.get_fastframe_selected() - print(f" Selected frame: {actual}") - - # Restore FastFrame state - scope.set_fastframe_state(False) - print(" FastFrame disabled (restored)") - - # Restore original settings - print("\nRestoring original waveform transfer settings...") - try: - scope.set_data_encoding(current_data_encoding) - scope.set_data_source(current_data_source) - scope.set_wfmoutpre_encoding(current_wfmoutpre_encoding) - scope.set_wfmoutpre_byte_count(current_byte_count) - scope.set_wfmoutpre_byte_order(current_byte_order) - print(" Settings restored") - except Exception as e: - print(f" Could not restore settings (connection may be in bad state): {e}") - - # Test error handling - print("\n=== Testing Error Handling ===\n") - - # Skip error handling tests if connection is already bad - try: - # Quick connectivity check - scope.query("*OPC?") - except Exception: - print("Connection appears to be in bad state, skipping error handling tests\n") - print("=== Waveform transfer tests completed (with some skipped due to connection issues) ===") - return - - print("Testing invalid data encoding (should raise ValueError)...") - try: - scope.set_data_encoding('INVALID') - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid data source (should raise ValueError)...") - try: - scope.set_data_source('CH5') - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid byte count (should raise ValueError)...") - try: - scope.set_wfmoutpre_byte_count(3) - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid byte order (should raise ValueError)...") - try: - scope.set_wfmoutpre_byte_order('INVALID') - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid FastFrame frame number (should raise ValueError)...") - try: - scope.set_fastframe_selected(-1) - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\nTesting invalid channel in acquire_waveform (should raise ValueError)...") - try: - scope.acquire_waveform(5) - print(" ERROR: Should have raised ValueError!") - except ValueError as e: - print(f" Correctly raised ValueError: {e}") - - print("\n=== All waveform transfer tests completed successfully! ===") - - except Exception as e: - print(f"\nError: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("\nDisconnected from oscilloscope") - - -if __name__ == "__main__": - main() diff --git a/pymso/test_waveform_simple.py b/pymso/test_waveform_simple.py deleted file mode 100755 index aeb3d8a..0000000 --- a/pymso/test_waveform_simple.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple focused test for waveform transfer functionality. -""" - -from tektronix_base import TektronixOscilloscopeBase - - -def main(): - """Simple test of core waveform transfer on CH1""" - - scope_ip = "192.168.10.105" - - print(f"Connecting to oscilloscope at {scope_ip}...") - - scope = TektronixOscilloscopeBase(resource_name=scope_ip, timeout=10.0) - - try: - # Connect to scope - scope.connect() - print(f"Connected to {scope.identify()}\n") - - # Test 1: Configure and transfer using low-level methods - print("=== Test 1: Low-level waveform transfer ===") - print("Configuring data transfer settings...") - scope.set_data_encoding('RIBinary') - scope.set_wfmoutpre_encoding('BINary') - scope.set_wfmoutpre_byte_count(1) - scope.set_wfmoutpre_byte_order('MSB') - scope.set_data_source('CH1') - - print("Transferring curve data from CH1...") - curve_data = scope.transfer_curve() - print(f" Received {len(curve_data)} bytes") - - print("Parsing curve data...") - values = scope.parse_curve_data(curve_data, byte_count=1, signed=True, byte_order='MSB') - print(f" Parsed {len(values)} samples") - print(f" First 10 values: {values[:10]}") - print(f" Min: {min(values)}, Max: {max(values)}, Avg: {sum(values)/len(values):.2f}") - - # Test 2: Use high-level acquire_waveform method - print("\n=== Test 2: High-level waveform acquisition ===") - print("Acquiring waveform from CH1...") - waveform = scope.acquire_waveform('CH1') - print(f" Acquired {len(waveform)} samples") - print(f" First 10 values: {waveform[:10]}") - print(f" Min: {min(waveform)}, Max: {max(waveform)}, Avg: {sum(waveform)/len(waveform):.2f}") - - # Test 3: Query waveform preamble - print("\n=== Test 3: Waveform preamble ===") - preamble = scope.query_wfmoutpre() - print(f" Preamble: {preamble[:150]}...") - - # Test 4: Transfer complete waveform (preamble + curve) - print("\n=== Test 4: Complete waveform transfer ===") - print("Transferring complete waveform...") - preamble_str, curve_bytes = scope.transfer_waveform() - print(f" Preamble length: {len(preamble_str)} chars") - print(f" Curve data: {len(curve_bytes)} bytes") - wf_values = scope.parse_curve_data(curve_bytes, byte_count=1, signed=True, byte_order='MSB') - print(f" Parsed {len(wf_values)} samples") - - print("\n=== All tests completed successfully! ===") - - except Exception as e: - print(f"\nError: {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - finally: - if scope.is_connected: - scope.disconnect() - print("\nDisconnected from oscilloscope") - - -if __name__ == "__main__": - main() diff --git a/pypewpewhops/LASER_I2C_PROTOCOL.md b/pypewpewhops/LASER_I2C_PROTOCOL.md deleted file mode 100644 index acf8744..0000000 --- a/pypewpewhops/LASER_I2C_PROTOCOL.md +++ /dev/null @@ -1,315 +0,0 @@ -# Coherent HOPS Laser I2C Protocol Documentation - -This document describes the I2C communication protocol used to control Coherent HOPS laser systems, extracted from the CohrHopsDemo v2.0.7 codebase. - -## Hardware Overview - -### FTDI Interface -- **Chip**: FT2232C (dual-channel USB) -- **Protocol**: I2C via MPSSE (Multi-Protocol Synchronous Serial Engine) -- **Library**: CohrFTCI2C.dll (Windows), use libftdi/libmpsse on Linux - -### I2C Configuration -| Parameter | Value/Range | -|-----------|-------------| -| Clock Divisor | 0 - 65535 | -| Modes | STANDARD, FAST | -| Control Bytes | 1 - 255 | -| Data Bytes | 1 - 65535 | - -### I2C Slave -- **Device**: NXP microcontroller -- **Role**: Intermediary between FTDI and laser hardware - ---- - -## I2C Library Functions - -These are the low-level FTDI I2C functions (from CohrFTCI2C.dll): - -| Function | Purpose | -|----------|---------| -| `I2C_GetNumDevices` | Enumerate connected I2C devices | -| `I2C_GetDeviceNameLocID` | Get device location identifier | -| `I2C_GetDeviceNameSerialNumber` | Get device serial number | -| `I2C_Open` | Open I2C device | -| `I2C_OpenEx` | Extended open with options | -| `I2C_OpenSerialNumber` | Open device by serial number | -| `I2C_InitDevice` | Initialize MPSSE interface | -| `I2C_SetMode` | Set STANDARD or FAST mode | -| `I2C_GetClock` | Get current clock divisor | -| `I2C_SetClock` | Set clock divisor | -| `I2C_SetLoopback` | Enable/disable loopback testing | -| `I2C_Write` | Write control + data bytes | -| `I2C_Read` | Read data bytes | -| `I2C_ReadAlt` | Alternative read function | -| `I2C_Close` | Close I2C device | -| `I2C_GetErrorCodeString` | Get error descriptions | - ---- - -## NXP Slave Operations - -The NXP microcontroller provides these I2C operations: - -| Method | Purpose | -|--------|---------| -| `NXP::Write` | Write data to I2C slave | -| `NXP::Read` | Read data from I2C slave | -| `NXP::WriteRegister` | Write to internal registers | -| `NXP::ReadRegister` | Read from internal registers | -| `NXP::WriteGPIO` | Control GPIO outputs | -| `NXP::ReadGPIO` | Read GPIO inputs | - ---- - -## I2C Transaction Format - -### Write Operation -``` -1. WriteControlBuffer: I2C slave address + W bit (0) -2. WriteDataBuffer: Register address + data - - BYTE mode: Single byte writes - - PAGE mode: Multi-byte writes -``` - -### Read Operation -``` -1. WriteControlBuffer: I2C slave address + R bit (1) -2. ReadDataBuffer: Receive response - - BYTE mode: Single byte reads - - BLOCK mode: Multi-byte reads -``` - ---- - -## High-Level Command Interface - -Commands are sent via `CohrHOPS_SendCommand()` using the format `?COMMAND` for queries. - -### System Information Commands - -| Command | Purpose | Example Response | -|---------|---------|------------------| -| `?HID` | Query Hardware ID | Device identifier | -| `?HTYPE` | Query Head Type | Head variant | -| `?HBDREV` | Query Head Board Revision | PCB revision | -| `?HEADDIO` | Query Head Digital I/O | DIO configuration | -| `?LASERMODEL` | Query Laser Model | G532, Tina, Mini00, MiniX | -| `?POWERUNITS` | Query Power Units | mW, W, etc. | -| `?WAVELENGTH` | Query Wavelength | 532nm, etc. | - -### Temperature Monitoring - -| Command | Purpose | -|---------|---------| -| `?TMAIN` | Main Heatsink Temperature | -| `?TBRF` | BRF (Birefringent Filter) Temperature | -| `?TSHG` | SHG (Second Harmonic Generator) Temperature | -| `?TTHG` | THG (Third Harmonic Generator) Temperature | -| `?TETA` | ETA Temperature | - -### Temperature Control (Setpoints) - -| Command | Purpose | -|---------|---------| -| `?TMAINCMD` | Get/Set Main Temperature Setpoint | -| `?TBRFCMD` | Get/Set BRF Temperature Setpoint | -| `?TSHGCMD` | Get/Set SHG Temperature Setpoint | -| `?TTHGCMD` | Get/Set THG Temperature Setpoint | -| `?TETACMD` | Get/Set ETA Temperature Setpoint | - -### Temperature Data - -| Command | Purpose | -|---------|---------| -| `?MAIND` | Main Temperature Data | -| `?BRFD` | BRF Temperature Data | -| `?SHGD` | SHG Temperature Data | -| `?THGD` | THG Temperature Data | -| `?ETAD` | ETA Temperature Data | - -### Power Control - -| Command | Purpose | -|---------|---------| -| `?PCMD` | Get/Set Power Command | -| `?PMEM` | Query Power Memory (stored settings) | -| `?PLIM` | Query Power Limits | - -### Current Control - -| Command | Purpose | -|---------|---------| -| `?CCMD` | Get/Set Current Command | -| `?CLIM` | Query Current Limits | -| `?CMODE` | Get/Set Control Mode | -| `?CMODECMD` | Get/Set Control Mode Command | - -### Digital I/O - -| Command | Purpose | -|---------|---------| -| `?PSDIO` | Power Supply Digital I/O | -| `?PSGLUEIN` | Power Supply Glue Logic Input | -| `?PSGLUEOUT` | Power Supply Glue Logic Output | - -### Monitoring & Status - -| Command | Purpose | -|---------|---------| -| `?ANA` | Query Analog Values | -| `?ANACMD` | Get/Set Analog Command | -| `?KSW` | Key Switch Status | -| `?KSWCMD` | Get/Set Key Switch Command | -| `?FAN` | Fan Status/Control | -| `?INT` | Interlock Status | -| `?REM` | Remote Control Status | -| `?EEH` | EEPROM Header | - -### Configuration Registers - -| Command | Purpose | -|---------|---------| -| `?CFG0` | Configuration Register 0 | -| `?CFG1` | Configuration Register 1 | -| `?CFG2` | Configuration Register 2 | -| `?CFG3` | Configuration Register 3 | - ---- - -## Supported Laser Models - -| Model | Description | -|-------|-------------| -| G532 | 532nm Green Laser | -| Tina | Proprietary Model | -| Mini00 | Compact Variant | -| MiniX | Extended Mini Variant | -| CommonLaser | Base Implementation | -| DummyLaser | Test/Simulation | - ---- - -## Linux Implementation Guide - -### Required Libraries - -For Linux implementation, use one of: -- **libftdi** + **libmpsse** - Direct FTDI MPSSE control -- **pylibftdi** - Python bindings for libftdi -- Standard Linux I2C (`/dev/i2c-*`) if FTDI exposes as I2C adapter - -### Installation (Debian/Ubuntu) - -```bash -sudo apt install libftdi-dev libmpsse-dev -``` - -### Basic Implementation Steps - -1. **Initialize FTDI Device** - ```c - // Find and open FT2232C device - ftdi_init(&ftdi); - ftdi_usb_open(&ftdi, 0x0403, 0x6010); // FTDI VID/PID - ``` - -2. **Configure MPSSE for I2C** - ```c - // Enable MPSSE mode - ftdi_set_bitmode(&ftdi, 0, BITMODE_MPSSE); - - // Set I2C clock speed - // Clock = 60MHz / ((1 + divisor) * 2) - ``` - -3. **Send I2C Commands** - ```c - // Write command to laser - i2c_write(slave_addr, "?HID", 4); - - // Read response - i2c_read(slave_addr, buffer, sizeof(buffer)); - ``` - -### Example: Query Laser Model - -```c -#include -#include - -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/ diff --git a/pypewpewhops/README.md b/pypewpewhops/README.md deleted file mode 100644 index 53c4be3..0000000 --- a/pypewpewhops/README.md +++ /dev/null @@ -1,283 +0,0 @@ -# PyPewPewHOPS - Coherent HOPS Laser Control Library - -Python library for controlling Coherent HOPS laser systems via I2C protocol through an FTDI FT2232C USB interface. - -## Features - -- Complete implementation of all documented I2C commands -- Support for system information queries -- Temperature monitoring and control for all sensors -- Power and current control -- Digital I/O operations -- Configuration register access -- Context manager support for safe resource handling -- Dummy laser simulator for development without hardware -- Comprehensive error handling - -## Installation - -### Requirements - -- Python 3.7+ -- FTDI FT2232C USB device -- libftdi library (for Linux) - -### Install Dependencies - -```bash -pip install -r requirements.txt -``` - -### Linux Setup - -On Linux, you may need to install libftdi: - -```bash -# Debian/Ubuntu -sudo apt install libftdi-dev - -# Fedora -sudo dnf install libftdi-devel -``` - -You may also need to add your user to the appropriate group: - -```bash -sudo usermod -a -G dialout $USER -sudo usermod -a -G plugdev $USER -``` - -Then log out and log back in for the changes to take effect. - -## Quick Start - -### Using the Simulator (No Hardware) - -```python -from coherent_hops_laser import DummyLaser - -with DummyLaser() as laser: - # Query system information - info = laser.get_system_info() - print(f"Model: {info.laser_model}") - print(f"Wavelength: {info.wavelength}") - - # Monitor temperatures - temps = laser.get_all_temperatures() - print(f"Main temperature: {temps.main}°C") - - # Control power - laser.set_power_command(100.0) - power = laser.get_power_command() - print(f"Power set to: {power} mW") -``` - -### Using Real Hardware - -```python -from coherent_hops_laser import CoherentHOPSLaser, I2CMode - -# Initialize laser controller -laser = CoherentHOPSLaser( - slave_address=0x50, # I2C slave address - i2c_mode=I2CMode.STANDARD # 100 kHz -) - -# Connect to FTDI device -laser.connect('ftdi://ftdi:2232/1') - -try: - # Query laser model - model = laser.get_laser_model() - print(f"Laser Model: {model}") - - # Get all temperatures - temps = laser.get_all_temperatures() - print(f"Temperatures: {temps}") - - # Set power - laser.set_power_command(50.0) - - # Check control mode - mode = laser.get_control_mode() - print(f"Control Mode: {mode}") - -finally: - laser.disconnect() -``` - -### Using Context Manager - -```python -from coherent_hops_laser import CoherentHOPSLaser - -with CoherentHOPSLaser() as laser: - laser.connect() - - # Your laser control code here - info = laser.get_system_info() - print(info) - -# Automatically disconnects -``` - -## Available Commands - -### System Information - -- `get_hardware_id()` - Hardware ID -- `get_head_type()` - Head type -- `get_head_board_revision()` - PCB revision -- `get_laser_model()` - Laser model (G532, Tina, Mini00, MiniX) -- `get_power_units()` - Power units (mW, W) -- `get_wavelength()` - Wavelength (e.g., 532nm) -- `get_system_info()` - All system info at once - -### Temperature Monitoring - -- `get_temperature_main()` - Main heatsink temperature -- `get_temperature_brf()` - BRF temperature -- `get_temperature_shg()` - SHG temperature -- `get_temperature_thg()` - THG temperature -- `get_temperature_eta()` - ETA temperature -- `get_all_temperatures()` - All temperatures at once - -### Temperature Control - -- `get_temperature_setpoint_main()` / `set_temperature_setpoint_main(temp)` -- `get_temperature_setpoint_brf()` / `set_temperature_setpoint_brf(temp)` -- `get_temperature_setpoint_shg()` / `set_temperature_setpoint_shg(temp)` -- `get_temperature_setpoint_thg()` / `set_temperature_setpoint_thg(temp)` -- `get_temperature_setpoint_eta()` / `set_temperature_setpoint_eta(temp)` - -### Power Control - -- `get_power_command()` / `set_power_command(power)` - Get/set power -- `get_power_memory()` - Stored power settings -- `get_power_limits()` - Power limits - -### Current Control - -- `get_current_command()` / `set_current_command(current)` - Get/set current -- `get_current_limits()` - Current limits -- `get_control_mode()` / `set_control_mode(mode)` - Control mode (POWER/CURRENT) - -### Status Monitoring - -- `get_key_switch_status()` - Key switch status -- `get_fan_status()` / `set_fan_control(value)` - Fan control -- `get_interlock_status()` - Interlock status -- `get_remote_control_status()` - Remote control status -- `get_analog_values()` - Analog sensor values - -### Configuration - -- `get_config_register_0()` / `set_config_register_0(value)` -- `get_config_register_1()` / `set_config_register_1(value)` -- `get_config_register_2()` / `set_config_register_2(value)` -- `get_config_register_3()` / `set_config_register_3(value)` - -See the [API documentation](LASER_I2C_PROTOCOL.md) for complete command reference. - -## Examples - -Run the example script: - -```bash -# Simulation mode (no hardware) -python3 example_usage.py 1 - -# Real hardware mode -python3 example_usage.py 2 - -# Continuous monitoring -python3 example_usage.py 3 -``` - -Or run the built-in test: - -```bash -python3 coherent_hops_laser.py -``` - -## Continuous Monitoring Example - -```python -from coherent_hops_laser import CoherentHOPSLaser -import time - -with CoherentHOPSLaser() as laser: - laser.connect() - - while True: - temps = laser.get_all_temperatures() - power = laser.get_power_command() - - print(f"Main: {temps.main:.1f}°C Power: {power:.1f}mW") - time.sleep(1) -``` - -## Troubleshooting - -### Cannot find FTDI device - -```bash -# Check if device is connected -lsusb | grep FTDI - -# Should show something like: -# Bus 001 Device 005: ID 0403:6010 Future Technology Devices International, Ltd FT2232C -``` - -### Permission denied - -Add your user to the dialout/plugdev group: - -```bash -sudo usermod -a -G dialout $USER -sudo usermod -a -G plugdev $USER -``` - -Then log out and back in. - -### I2C communication errors - -- Verify correct slave address (default: 0x50) -- Check I2C speed (try I2CMode.STANDARD instead of FAST) -- Verify physical connections -- Check for other devices on the bus - -### Import errors - -```bash -# Install pyftdi -pip install pyftdi - -# If that fails, try: -pip install --user pyftdi -``` - -## Architecture - -- **CoherentHOPSLaser**: Main class for real hardware control -- **DummyLaser**: Simulator for development without hardware -- **I2CController**: Low-level FTDI I2C communication (from pyftdi) -- **LaserInfo / TemperatureStatus**: Data classes for structured responses - -## Safety Notes - -- Always verify power levels before enabling laser output -- Monitor temperatures during operation -- Check interlock status before operation -- Use appropriate laser safety equipment -- Follow all manufacturer safety guidelines - -## License - -This implementation is based on the Coherent HOPS Demo v2.0.7 protocol documentation. - -## References - -- FTDI MPSSE Documentation: https://ftdichip.com/software-examples/mpsse-projects/ -- PyFTDI: https://github.com/eblot/pyftdi -- Original Protocol Documentation: [LASER_I2C_PROTOCOL.md](LASER_I2C_PROTOCOL.md) diff --git a/pypewpewhops/coherent_hops_laser.py b/pypewpewhops/coherent_hops_laser.py deleted file mode 100644 index ed75ef3..0000000 --- a/pypewpewhops/coherent_hops_laser.py +++ /dev/null @@ -1,773 +0,0 @@ -""" -Coherent HOPS Laser I2C Control Library - -This module provides a Python interface to control Coherent HOPS laser systems -via I2C protocol through an FTDI FT2232C USB interface. - -Dependencies: - pip install pyftdi - -Usage: - from coherent_hops_laser import CoherentHOPSLaser - - laser = CoherentHOPSLaser() - laser.connect() - - # Query system information - model = laser.get_laser_model() - wavelength = laser.get_wavelength() - - # Monitor temperatures - main_temp = laser.get_temperature_main() - - # Control power - laser.set_power_command(100.0) # Set power in mW or W - - laser.disconnect() -""" - -from typing import Optional, Union, List -from dataclasses import dataclass -from enum import Enum -import time -import logging - -try: - from pyftdi.i2c import I2cController, I2cNackError -except ImportError: - raise ImportError( - "pyftdi library is required. Install with: pip install pyftdi" - ) - - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class I2CMode(Enum): - """I2C communication modes""" - STANDARD = 100000 # 100 kHz - FAST = 400000 # 400 kHz - - -class ControlMode(Enum): - """Laser control modes""" - POWER = "POWER" - CURRENT = "CURRENT" - - -@dataclass -class LaserInfo: - """Laser system information""" - hardware_id: Optional[str] = None - head_type: Optional[str] = None - head_board_revision: Optional[str] = None - laser_model: Optional[str] = None - power_units: Optional[str] = None - wavelength: Optional[str] = None - - -@dataclass -class TemperatureStatus: - """Temperature monitoring data""" - main: Optional[float] = None - brf: Optional[float] = None - shg: Optional[float] = None - thg: Optional[float] = None - eta: Optional[float] = None - - -class CoherentHOPSLaser: - """ - Main interface class for Coherent HOPS laser control via I2C. - - This class provides high-level methods for all documented laser commands - including system queries, temperature control, power/current management, - and digital I/O operations. - """ - - # Default I2C slave address for NXP microcontroller - DEFAULT_SLAVE_ADDRESS = 0x50 - - # FTDI USB VID/PID for FT2232C - FTDI_VID = 0x0403 - FTDI_PID = 0x6010 - - def __init__( - self, - slave_address: int = DEFAULT_SLAVE_ADDRESS, - i2c_mode: I2CMode = I2CMode.STANDARD, - timeout: float = 1.0 - ): - """ - Initialize the laser controller. - - Args: - slave_address: I2C slave address of the NXP microcontroller - i2c_mode: I2C communication speed mode - timeout: Command timeout in seconds - """ - self.slave_address = slave_address - self.i2c_mode = i2c_mode - self.timeout = timeout - - self._i2c_controller = I2cController() - self._i2c_slave = None - self._connected = False - - def connect(self, url: str = 'ftdi://ftdi:2232/1') -> None: - """ - Connect to the FTDI I2C device. - - Args: - url: FTDI device URL (default: first FT2232C device, channel 1) - Examples: - - 'ftdi://ftdi:2232/1' - First FT2232 device, channel 1 - - 'ftdi://ftdi:2232:SERIAL/1' - Device with specific serial number - - Raises: - IOError: If connection fails - """ - try: - # Configure I2C controller - self._i2c_controller.configure(url, frequency=self.i2c_mode.value) - - # Get I2C slave interface - self._i2c_slave = self._i2c_controller.get_port(self.slave_address) - - self._connected = True - logger.info(f"Connected to laser at I2C address 0x{self.slave_address:02X}") - - except Exception as e: - logger.error(f"Failed to connect to I2C device: {e}") - raise IOError(f"I2C connection failed: {e}") - - def disconnect(self) -> None: - """Disconnect from the I2C device.""" - if self._connected: - self._i2c_controller.terminate() - self._connected = False - logger.info("Disconnected from laser") - - def _ensure_connected(self) -> None: - """Verify device is connected before operations.""" - if not self._connected: - raise RuntimeError("Not connected. Call connect() first.") - - def _send_command(self, command: str, value: Optional[str] = None) -> str: - """ - Send a command to the laser and read response. - - Args: - command: Command string (e.g., 'HID', 'LASERMODEL') - value: Optional value for set commands - - Returns: - Response string from the laser - - Raises: - I2cNackError: If I2C communication fails - TimeoutError: If response timeout occurs - """ - self._ensure_connected() - - # Format command: query = ?COMMAND, set = COMMAND=VALUE - if value is not None: - cmd_str = f"{command}={value}" - else: - cmd_str = f"?{command}" - - cmd_bytes = cmd_str.encode('ascii') - - try: - # Write command - self._i2c_slave.write(cmd_bytes) - - # Small delay for laser to process - time.sleep(0.01) - - # Read response (max 256 bytes) - response = self._i2c_slave.read(256) - - # Decode and strip null bytes and whitespace - result = response.decode('ascii', errors='ignore').rstrip('\x00').strip() - - logger.debug(f"Command: {cmd_str} -> Response: {result}") - return result - - except I2cNackError as e: - logger.error(f"I2C NACK error for command {cmd_str}: {e}") - raise - except Exception as e: - logger.error(f"Communication error for command {cmd_str}: {e}") - raise - - # ========================================== - # System Information Commands - # ========================================== - - def get_hardware_id(self) -> str: - """Query Hardware ID.""" - return self._send_command('HID') - - def get_head_type(self) -> str: - """Query Head Type.""" - return self._send_command('HTYPE') - - def get_head_board_revision(self) -> str: - """Query Head Board Revision.""" - return self._send_command('HBDREV') - - def get_head_digital_io(self) -> str: - """Query Head Digital I/O configuration.""" - return self._send_command('HEADDIO') - - def get_laser_model(self) -> str: - """ - Query Laser Model. - - Returns: - Model name (e.g., 'G532', 'Tina', 'Mini00', 'MiniX') - """ - return self._send_command('LASERMODEL') - - def get_power_units(self) -> str: - """ - Query Power Units. - - Returns: - Power units (e.g., 'mW', 'W') - """ - return self._send_command('POWERUNITS') - - def get_wavelength(self) -> str: - """ - Query Wavelength. - - Returns: - Wavelength (e.g., '532nm') - """ - return self._send_command('WAVELENGTH') - - def get_system_info(self) -> LaserInfo: - """ - Query all system information. - - Returns: - LaserInfo dataclass with all system parameters - """ - return LaserInfo( - hardware_id=self.get_hardware_id(), - head_type=self.get_head_type(), - head_board_revision=self.get_head_board_revision(), - laser_model=self.get_laser_model(), - power_units=self.get_power_units(), - wavelength=self.get_wavelength() - ) - - # ========================================== - # Temperature Monitoring - # ========================================== - - def get_temperature_main(self) -> float: - """ - Get Main Heatsink Temperature. - - Returns: - Temperature in degrees Celsius - """ - response = self._send_command('TMAIN') - return float(response) - - def get_temperature_brf(self) -> float: - """ - Get BRF (Birefringent Filter) Temperature. - - Returns: - Temperature in degrees Celsius - """ - response = self._send_command('TBRF') - return float(response) - - def get_temperature_shg(self) -> float: - """ - Get SHG (Second Harmonic Generator) Temperature. - - Returns: - Temperature in degrees Celsius - """ - response = self._send_command('TSHG') - return float(response) - - def get_temperature_thg(self) -> float: - """ - Get THG (Third Harmonic Generator) Temperature. - - Returns: - Temperature in degrees Celsius - """ - response = self._send_command('TTHG') - return float(response) - - def get_temperature_eta(self) -> float: - """ - Get ETA Temperature. - - Returns: - Temperature in degrees Celsius - """ - response = self._send_command('TETA') - return float(response) - - def get_all_temperatures(self) -> TemperatureStatus: - """ - Query all temperature sensors. - - Returns: - TemperatureStatus dataclass with all temperature readings - """ - return TemperatureStatus( - main=self.get_temperature_main(), - brf=self.get_temperature_brf(), - shg=self.get_temperature_shg(), - thg=self.get_temperature_thg(), - eta=self.get_temperature_eta() - ) - - # ========================================== - # Temperature Control (Setpoints) - # ========================================== - - def get_temperature_setpoint_main(self) -> float: - """Get Main Temperature Setpoint.""" - response = self._send_command('TMAINCMD') - return float(response) - - def set_temperature_setpoint_main(self, temperature: float) -> None: - """Set Main Temperature Setpoint.""" - self._send_command('TMAINCMD', str(temperature)) - - def get_temperature_setpoint_brf(self) -> float: - """Get BRF Temperature Setpoint.""" - response = self._send_command('TBRFCMD') - return float(response) - - def set_temperature_setpoint_brf(self, temperature: float) -> None: - """Set BRF Temperature Setpoint.""" - self._send_command('TBRFCMD', str(temperature)) - - def get_temperature_setpoint_shg(self) -> float: - """Get SHG Temperature Setpoint.""" - response = self._send_command('TSHGCMD') - return float(response) - - def set_temperature_setpoint_shg(self, temperature: float) -> None: - """Set SHG Temperature Setpoint.""" - self._send_command('TSHGCMD', str(temperature)) - - def get_temperature_setpoint_thg(self) -> float: - """Get THG Temperature Setpoint.""" - response = self._send_command('TTHGCMD') - return float(response) - - def set_temperature_setpoint_thg(self, temperature: float) -> None: - """Set THG Temperature Setpoint.""" - self._send_command('TTHGCMD', str(temperature)) - - def get_temperature_setpoint_eta(self) -> float: - """Get ETA Temperature Setpoint.""" - response = self._send_command('TETACMD') - return float(response) - - def set_temperature_setpoint_eta(self, temperature: float) -> None: - """Set ETA Temperature Setpoint.""" - self._send_command('TETACMD', str(temperature)) - - # ========================================== - # Temperature Data - # ========================================== - - def get_temperature_data_main(self) -> str: - """Get Main Temperature Data.""" - return self._send_command('MAIND') - - def get_temperature_data_brf(self) -> str: - """Get BRF Temperature Data.""" - return self._send_command('BRFD') - - def get_temperature_data_shg(self) -> str: - """Get SHG Temperature Data.""" - return self._send_command('SHGD') - - def get_temperature_data_thg(self) -> str: - """Get THG Temperature Data.""" - return self._send_command('THGD') - - def get_temperature_data_eta(self) -> str: - """Get ETA Temperature Data.""" - return self._send_command('ETAD') - - # ========================================== - # Power Control - # ========================================== - - def get_power_command(self) -> float: - """ - Get Power Command value. - - Returns: - Power value in units from get_power_units() - """ - response = self._send_command('PCMD') - return float(response) - - def set_power_command(self, power: float) -> None: - """ - Set Power Command value. - - Args: - power: Power value in units from get_power_units() - """ - self._send_command('PCMD', str(power)) - - def get_power_memory(self) -> str: - """Query Power Memory (stored settings).""" - return self._send_command('PMEM') - - def get_power_limits(self) -> str: - """Query Power Limits.""" - return self._send_command('PLIM') - - # ========================================== - # Current Control - # ========================================== - - def get_current_command(self) -> float: - """ - Get Current Command value. - - Returns: - Current value in Amperes - """ - response = self._send_command('CCMD') - return float(response) - - def set_current_command(self, current: float) -> None: - """ - Set Current Command value. - - Args: - current: Current value in Amperes - """ - self._send_command('CCMD', str(current)) - - def get_current_limits(self) -> str: - """Query Current Limits.""" - return self._send_command('CLIM') - - def get_control_mode(self) -> str: - """ - Get Control Mode. - - Returns: - Control mode (e.g., 'POWER' or 'CURRENT') - """ - return self._send_command('CMODE') - - def set_control_mode(self, mode: Union[str, ControlMode]) -> None: - """ - Set Control Mode. - - Args: - mode: Control mode ('POWER' or 'CURRENT', or ControlMode enum) - """ - if isinstance(mode, ControlMode): - mode = mode.value - self._send_command('CMODE', mode) - - def get_control_mode_command(self) -> str: - """Get Control Mode Command.""" - return self._send_command('CMODECMD') - - def set_control_mode_command(self, mode: str) -> None: - """Set Control Mode Command.""" - self._send_command('CMODECMD', mode) - - # ========================================== - # Digital I/O - # ========================================== - - def get_ps_digital_io(self) -> str: - """Get Power Supply Digital I/O status.""" - return self._send_command('PSDIO') - - def get_ps_glue_input(self) -> str: - """Get Power Supply Glue Logic Input status.""" - return self._send_command('PSGLUEIN') - - def get_ps_glue_output(self) -> str: - """Get Power Supply Glue Logic Output status.""" - return self._send_command('PSGLUEOUT') - - def set_ps_glue_output(self, value: str) -> None: - """Set Power Supply Glue Logic Output.""" - self._send_command('PSGLUEOUT', value) - - # ========================================== - # Monitoring & Status - # ========================================== - - def get_analog_values(self) -> str: - """Query Analog Values.""" - return self._send_command('ANA') - - def get_analog_command(self) -> str: - """Get Analog Command.""" - return self._send_command('ANACMD') - - def set_analog_command(self, value: str) -> None: - """Set Analog Command.""" - self._send_command('ANACMD', value) - - def get_key_switch_status(self) -> str: - """Get Key Switch Status.""" - return self._send_command('KSW') - - def get_key_switch_command(self) -> str: - """Get Key Switch Command.""" - return self._send_command('KSWCMD') - - def set_key_switch_command(self, value: str) -> None: - """Set Key Switch Command.""" - self._send_command('KSWCMD', value) - - def get_fan_status(self) -> str: - """Get Fan Status/Control.""" - return self._send_command('FAN') - - def set_fan_control(self, value: str) -> None: - """Set Fan Control.""" - self._send_command('FAN', value) - - def get_interlock_status(self) -> str: - """Get Interlock Status.""" - return self._send_command('INT') - - def get_remote_control_status(self) -> str: - """Get Remote Control Status.""" - return self._send_command('REM') - - def get_eeprom_header(self) -> str: - """Get EEPROM Header.""" - return self._send_command('EEH') - - # ========================================== - # Configuration Registers - # ========================================== - - def get_config_register_0(self) -> str: - """Get Configuration Register 0.""" - return self._send_command('CFG0') - - def set_config_register_0(self, value: str) -> None: - """Set Configuration Register 0.""" - self._send_command('CFG0', value) - - def get_config_register_1(self) -> str: - """Get Configuration Register 1.""" - return self._send_command('CFG1') - - def set_config_register_1(self, value: str) -> None: - """Set Configuration Register 1.""" - self._send_command('CFG1', value) - - def get_config_register_2(self) -> str: - """Get Configuration Register 2.""" - return self._send_command('CFG2') - - def set_config_register_2(self, value: str) -> None: - """Set Configuration Register 2.""" - self._send_command('CFG2', value) - - def get_config_register_3(self) -> str: - """Get Configuration Register 3.""" - return self._send_command('CFG3') - - def set_config_register_3(self, value: str) -> None: - """Set Configuration Register 3.""" - self._send_command('CFG3', value) - - # ========================================== - # Context Manager Support - # ========================================== - - def __enter__(self): - """Context manager entry.""" - if not self._connected: - self.connect() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - self.disconnect() - return False - - -class DummyLaser(CoherentHOPSLaser): - """ - Simulated laser for testing without hardware. - - This class provides dummy responses for all commands to enable - software development and testing without physical hardware. - """ - - def __init__(self): - """Initialize dummy laser (no I2C connection needed).""" - super().__init__() - self._connected = True # Simulate connection - - # Simulated state - self._power_cmd = 50.0 - self._current_cmd = 1.5 - self._control_mode = 'POWER' - self._temps = { - 'main': 25.0, - 'brf': 30.0, - 'shg': 35.0, - 'thg': 32.0, - 'eta': 28.0 - } - self._temp_setpoints = { - 'main': 25.0, - 'brf': 30.0, - 'shg': 35.0, - 'thg': 32.0, - 'eta': 28.0 - } - - def connect(self, url: str = 'dummy') -> None: - """Dummy connection (always succeeds).""" - self._connected = True - logger.info("Connected to DummyLaser (simulation mode)") - - def _send_command(self, command: str, value: Optional[str] = None) -> str: - """Simulate command responses.""" - logger.debug(f"DummyLaser command: {command}, value: {value}") - - # Handle set commands - if value is not None: - if command == 'PCMD': - self._power_cmd = float(value) - return value - elif command == 'CCMD': - self._current_cmd = float(value) - return value - elif command == 'CMODE': - self._control_mode = value - return value - elif 'CMD' in command and 'T' in command: - # Temperature setpoint - key = command.replace('CMD', '').replace('T', '').lower() - if key in self._temp_setpoints: - self._temp_setpoints[key] = float(value) - return value - return 'OK' - - # Handle query commands - responses = { - 'HID': 'HOPS-12345', - 'HTYPE': 'Standard', - 'HBDREV': 'Rev 2.1', - 'HEADDIO': '0xFF', - 'LASERMODEL': 'G532', - 'POWERUNITS': 'mW', - 'WAVELENGTH': '532nm', - 'TMAIN': str(self._temps['main']), - 'TBRF': str(self._temps['brf']), - 'TSHG': str(self._temps['shg']), - 'TTHG': str(self._temps['thg']), - 'TETA': str(self._temps['eta']), - 'TMAINCMD': str(self._temp_setpoints['main']), - 'TBRFCMD': str(self._temp_setpoints['brf']), - 'TSHGCMD': str(self._temp_setpoints['shg']), - 'TTHGCMD': str(self._temp_setpoints['thg']), - 'TETACMD': str(self._temp_setpoints['eta']), - 'MAIND': 'MainTempData', - 'BRFD': 'BRFTempData', - 'SHGD': 'SHGTempData', - 'THGD': 'THGTempData', - 'ETAD': 'ETATempData', - 'PCMD': str(self._power_cmd), - 'PMEM': '100', - 'PLIM': '0-200', - 'CCMD': str(self._current_cmd), - 'CLIM': '0-5', - 'CMODE': self._control_mode, - 'CMODECMD': self._control_mode, - 'PSDIO': '0x00', - 'PSGLUEIN': '0x00', - 'PSGLUEOUT': '0x00', - 'ANA': '0,0,0,0', - 'ANACMD': '0', - 'KSW': 'ON', - 'KSWCMD': 'ON', - 'FAN': 'AUTO', - 'INT': 'OK', - 'REM': 'ENABLED', - 'EEH': 'EEPROM_V1', - 'CFG0': '0x00', - 'CFG1': '0x00', - 'CFG2': '0x00', - 'CFG3': '0x00', - } - - return responses.get(command, 'UNKNOWN') - - -if __name__ == '__main__': - """Example usage and testing.""" - - # Test with dummy laser - print("=== Testing with DummyLaser ===\n") - - with DummyLaser() as laser: - # System info - print("System Information:") - info = laser.get_system_info() - print(f" Model: {info.laser_model}") - print(f" Wavelength: {info.wavelength}") - print(f" Power Units: {info.power_units}") - print(f" Hardware ID: {info.hardware_id}") - print() - - # Temperature monitoring - print("Temperature Status:") - temps = laser.get_all_temperatures() - print(f" Main: {temps.main}°C") - print(f" BRF: {temps.brf}°C") - print(f" SHG: {temps.shg}°C") - print(f" THG: {temps.thg}°C") - print(f" ETA: {temps.eta}°C") - print() - - # Power control - print("Power Control:") - current_power = laser.get_power_command() - print(f" Current Power: {current_power} {info.power_units}") - laser.set_power_command(75.0) - new_power = laser.get_power_command() - print(f" New Power: {new_power} {info.power_units}") - print() - - # Control mode - print("Control Mode:") - mode = laser.get_control_mode() - print(f" Current Mode: {mode}") - print() - - print("\n=== For real hardware, use: ===") - print("laser = CoherentHOPSLaser()") - print("laser.connect('ftdi://ftdi:2232/1')") - print("# ... perform operations ...") - print("laser.disconnect()") diff --git a/pypewpewhops/example_usage.py b/pypewpewhops/example_usage.py deleted file mode 100644 index f254afb..0000000 --- a/pypewpewhops/example_usage.py +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env python3 -""" -Example usage of the Coherent HOPS Laser control library. - -This script demonstrates how to use the library to control a real laser system. -""" - -from coherent_hops_laser import CoherentHOPSLaser, DummyLaser, I2CMode -import time - - -def demo_system_info(laser): - """Demonstrate system information queries.""" - print("=" * 60) - print("SYSTEM INFORMATION") - print("=" * 60) - - info = laser.get_system_info() - print(f"Hardware ID: {info.hardware_id}") - print(f"Laser Model: {info.laser_model}") - print(f"Wavelength: {info.wavelength}") - print(f"Power Units: {info.power_units}") - print(f"Head Type: {info.head_type}") - print(f"Board Revision: {info.head_board_revision}") - print() - - -def demo_temperature_monitoring(laser): - """Demonstrate temperature monitoring.""" - print("=" * 60) - print("TEMPERATURE MONITORING") - print("=" * 60) - - temps = laser.get_all_temperatures() - print(f"Main Heatsink: {temps.main:.2f}°C") - print(f"BRF (Birefringent): {temps.brf:.2f}°C") - print(f"SHG (2nd Harmonic): {temps.shg:.2f}°C") - print(f"THG (3rd Harmonic): {temps.thg:.2f}°C") - print(f"ETA: {temps.eta:.2f}°C") - print() - - -def demo_temperature_control(laser): - """Demonstrate temperature setpoint control.""" - print("=" * 60) - print("TEMPERATURE CONTROL") - print("=" * 60) - - # Read current setpoints - print("Current Setpoints:") - print(f" Main: {laser.get_temperature_setpoint_main():.2f}°C") - print(f" BRF: {laser.get_temperature_setpoint_brf():.2f}°C") - print(f" SHG: {laser.get_temperature_setpoint_shg():.2f}°C") - print() - - # Example: Set a new setpoint (commented out for safety) - # print("Setting Main temperature setpoint to 26.0°C...") - # laser.set_temperature_setpoint_main(26.0) - # print(f" New setpoint: {laser.get_temperature_setpoint_main():.2f}°C") - print("(Temperature setpoint modification disabled in demo)") - print() - - -def demo_power_control(laser): - """Demonstrate power control.""" - print("=" * 60) - print("POWER CONTROL") - print("=" * 60) - - units = laser.get_power_units() - current_power = laser.get_power_command() - print(f"Current Power: {current_power} {units}") - - power_limits = laser.get_power_limits() - print(f"Power Limits: {power_limits}") - - power_memory = laser.get_power_memory() - print(f"Power Memory: {power_memory}") - print() - - # Example: Set power (commented out for safety) - # print("Setting power to 100.0 mW...") - # laser.set_power_command(100.0) - # print(f" New power: {laser.get_power_command()} {units}") - print("(Power modification disabled in demo)") - print() - - -def demo_current_control(laser): - """Demonstrate current control.""" - print("=" * 60) - print("CURRENT CONTROL") - print("=" * 60) - - current = laser.get_current_command() - print(f"Current Command: {current} A") - - limits = laser.get_current_limits() - print(f"Current Limits: {limits}") - - mode = laser.get_control_mode() - print(f"Control Mode: {mode}") - print() - - -def demo_status_monitoring(laser): - """Demonstrate status monitoring.""" - print("=" * 60) - print("STATUS MONITORING") - print("=" * 60) - - print(f"Key Switch: {laser.get_key_switch_status()}") - print(f"Fan Status: {laser.get_fan_status()}") - print(f"Interlock: {laser.get_interlock_status()}") - print(f"Remote Control: {laser.get_remote_control_status()}") - print(f"Analog Values: {laser.get_analog_values()}") - print() - - -def demo_configuration(laser): - """Demonstrate configuration register access.""" - print("=" * 60) - print("CONFIGURATION REGISTERS") - print("=" * 60) - - print(f"Config Register 0: {laser.get_config_register_0()}") - print(f"Config Register 1: {laser.get_config_register_1()}") - print(f"Config Register 2: {laser.get_config_register_2()}") - print(f"Config Register 3: {laser.get_config_register_3()}") - print() - - -def main_dummy_demo(): - """Run demo with simulated hardware.""" - print("\n" + "=" * 60) - print("COHERENT HOPS LASER CONTROL - SIMULATION MODE") - print("=" * 60 + "\n") - - with DummyLaser() as laser: - demo_system_info(laser) - demo_temperature_monitoring(laser) - demo_temperature_control(laser) - demo_power_control(laser) - demo_current_control(laser) - demo_status_monitoring(laser) - demo_configuration(laser) - - # Demonstrate power control - print("=" * 60) - print("POWER CONTROL DEMONSTRATION (Simulation)") - print("=" * 60) - print(f"Initial power: {laser.get_power_command()} mW") - laser.set_power_command(125.0) - print(f"After setting to 125.0 mW: {laser.get_power_command()} mW") - print() - - -def main_real_hardware(): - """Run demo with real hardware.""" - print("\n" + "=" * 60) - print("COHERENT HOPS LASER CONTROL - REAL HARDWARE") - print("=" * 60 + "\n") - - # Configure for your specific setup - FTDI_URL = 'ftdi://ftdi:2232/1' # Adjust if needed - SLAVE_ADDRESS = 0x50 # Default NXP slave address - I2C_FREQUENCY = I2CMode.STANDARD # or I2CMode.FAST - - try: - # Connect to laser - laser = CoherentHOPSLaser( - slave_address=SLAVE_ADDRESS, - i2c_mode=I2C_FREQUENCY - ) - - print(f"Connecting to FTDI device at {FTDI_URL}...") - laser.connect(FTDI_URL) - print("Connected successfully!\n") - - # Run demos - demo_system_info(laser) - demo_temperature_monitoring(laser) - demo_status_monitoring(laser) - demo_power_control(laser) - demo_current_control(laser) - - # Clean disconnect - laser.disconnect() - print("Disconnected successfully.") - - except Exception as e: - print(f"Error: {e}") - print("\nTroubleshooting:") - print("1. Check FTDI device is connected (lsusb | grep FTDI)") - print("2. Verify user permissions (add user to 'dialout' or 'plugdev' group)") - print("3. Check FTDI URL matches your device") - print("4. Try: sudo python3 example_usage.py (not recommended long-term)") - - -def continuous_monitoring_example(): - """Example of continuous temperature and power monitoring.""" - print("\n" + "=" * 60) - print("CONTINUOUS MONITORING EXAMPLE") - print("=" * 60 + "\n") - - with DummyLaser() as laser: - print("Monitoring laser parameters (5 iterations)...") - print("Press Ctrl+C to stop\n") - - try: - for i in range(5): - temps = laser.get_all_temperatures() - power = laser.get_power_command() - mode = laser.get_control_mode() - - print(f"[{i+1}] T_main={temps.main:.1f}°C " - f"T_shg={temps.shg:.1f}°C " - f"Power={power:.1f}mW " - f"Mode={mode}") - - time.sleep(1) - - except KeyboardInterrupt: - print("\nMonitoring stopped.") - - -if __name__ == '__main__': - import sys - - print("Coherent HOPS Laser Control - Example Usage\n") - print("Available demos:") - print(" 1. Simulation mode (no hardware required)") - print(" 2. Real hardware mode") - print(" 3. Continuous monitoring example") - print() - - if len(sys.argv) > 1: - choice = sys.argv[1] - else: - choice = input("Select demo (1/2/3) [default: 1]: ").strip() or "1" - - if choice == "1": - main_dummy_demo() - print("\nContinuous monitoring demo:") - continuous_monitoring_example() - elif choice == "2": - main_real_hardware() - elif choice == "3": - continuous_monitoring_example() - else: - print("Invalid choice. Use 1, 2, or 3.") - sys.exit(1) - - print("\n" + "=" * 60) - print("Demo complete!") - print("=" * 60) diff --git a/pypewpewhops/requirements.txt b/pypewpewhops/requirements.txt deleted file mode 100644 index 6baeed6..0000000 --- a/pypewpewhops/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pyftdi>=0.54.0 diff --git a/requirements.txt b/requirements.txt index 0d128d2..d21669d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,9 @@ -# 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 +pytest>=7.0.0 +pytest-qt>=4.0.0 +pyueye>=4.95.0 +numpy>=1.20.0 diff --git a/scanengine/__init__.py b/scanengine/__init__.py new file mode 100644 index 0000000..fe89eb4 --- /dev/null +++ b/scanengine/__init__.py @@ -0,0 +1,2 @@ +"""ScanEngine-3 main application package""" +__version__ = "3.0.0" diff --git a/scanengine/app.py b/scanengine/app.py new file mode 100644 index 0000000..0e55cc4 --- /dev/null +++ b/scanengine/app.py @@ -0,0 +1,3432 @@ +#!/usr/bin/env python3 +""" +Scanengine 3 Main Application +Displays the main launcher, scan wizard, and options dialog +""" + +import sys +import os +import json +import ipaddress +from pathlib import Path +from PyQt6 import QtWidgets, QtCore, QtGui, uic +from typing import Optional +import numpy as np + +# Import laser drivers +from hardware.coherent_hops_laser import CoherentHOPSLaser, DummyLaser +from hardware.helios_laser import HeliosLaser, PulseMode + +# Import camera driver +from hardware.uc480_camera import UC480Camera, CameraStreamThread + +# Import stage controller and motion worker +from hardware.bbd202 import MotionController +from scanengine.motion_worker import MotionWorker + +# Import scan planning tool +from scanning.stage_scan_plan_generator import StageScanPlanGenerator + + +class ScanWorker(QtCore.QObject): + """ + Worker object for handling scanning in a separate thread. + + NOTE: Motion control logic has been removed. You need to implement + your own motion control in run_scan() method. + """ + + # Signals + scan_started = QtCore.pyqtSignal() + scan_completed = QtCore.pyqtSignal() + scan_failed = QtCore.pyqtSignal(str) + angle_started = QtCore.pyqtSignal(int, int) # angle_idx, total_angles + line_started = QtCore.pyqtSignal(int, int, float) # line_idx, total_lines, y_position + current_progress = QtCore.pyqtSignal(int) # Current scan progress % + overall_progress = QtCore.pyqtSignal(int) # Overall progress % + status_message = QtCore.pyqtSignal(str) # Status text + + def __init__(self, scan_params, motion_worker): + super().__init__() + self.scan_params = scan_params + self.motion_worker = motion_worker + self.should_stop = False + + @QtCore.pyqtSlot() + def run_scan(self): + """ + Execute the full scanning process. + + TODO: Implement your own motion control logic here. + + Available data: + self.scan_params - dict containing: + 'scan_boxes' - list of scan box dicts with: + 'angle_degrees' - rotation angle + 'start' - (x_start, y_start) in mm + 'end' - (x_end, y_end) in mm + 'row_spacing' - spacing between rows in mm + + self.motion_worker.controller - MotionController instance (if connected) + + self.should_stop - set to True when user requests abort + + Signals to emit: + self.scan_started.emit() - at start + self.angle_started.emit(angle_idx, total_angles) - when starting each angle + self.line_started.emit(line_idx, total_lines, y_pos) - when starting each line + self.current_progress.emit(percent) - progress for current angle + self.overall_progress.emit(percent) - overall progress + self.status_message.emit(message) - status updates + self.scan_completed.emit() - on successful completion + self.scan_failed.emit(error_msg) - on failure + """ + # Pause motion worker polling during scan to avoid conflicts + if self.motion_worker: + self.motion_worker.scanning_active = True + + try: + self.scan_started.emit() + + scan_boxes = self.scan_params.get('scan_boxes', []) + num_angles = len(scan_boxes) + row_spacing = self.scan_params.get('row_spacing', 0.1) + + print(f"Scan worker: Starting scan with {num_angles} angles") + print(f"Row spacing: {row_spacing} mm") + + controller = self.motion_worker.controller if self.motion_worker else None + if not controller: + self.scan_failed.emit("No motion controller connected") + return + + # TODO: Implement your motion control logic here + # + # For each angle in scan_boxes: + # - Move to start position (x_start, y_start) + # - For each row from y_start to y_end with row_spacing: + # - Move X from x_start to x_end (flying scan) + # - Move to next row (X back to x_start, Y to next row) + # + # The MotionController provides these methods: + # controller.move_to_fast(x=mm, y=mm) - send move commands + # controller.get_position(dest, timeout) - get current position + # controller.poll_until_idle(tolerance, timeout) - poll until settled + # controller.set_velocity_params(dest, min_vel, accel, max_vel) + # controller.start_update_messages() - enable status updates + # etc. + + self.scan_failed.emit("Motion control not implemented - please implement run_scan()") + + except Exception as e: + print(f"ERROR in scan worker: {e}") + import traceback + traceback.print_exc() + self.scan_failed.emit(str(e)) + finally: + # Re-enable motion worker polling + if self.motion_worker: + self.motion_worker.scanning_active = False + + def stop(self): + """Stop the scanning process""" + self.should_stop = True + + +class ScanGraphicsView(QtWidgets.QGraphicsView): + """Custom QGraphicsView for interactive scan area drawing""" + + # Signal emitted when user finishes drawing a rectangle (x_start, y_start, x_delta, y_delta in mm) + rectangle_drawn = QtCore.pyqtSignal(float, float, float, float) + + def __init__(self, parent=None): + super().__init__(parent) + self.draw_mode_enabled = False + self.pixels_per_mm = 6.0 # Will be set by parent + + # Drawing state + self.is_drawing = False + self.draw_start_point: Optional[QtCore.QPointF] = None + self.draw_current_point: Optional[QtCore.QPointF] = None + self.temp_rect_item: Optional[QtWidgets.QGraphicsRectItem] = None + + # Set cursor for better UX + self.default_cursor = QtCore.Qt.CursorShape.ArrowCursor + self.draw_cursor = QtCore.Qt.CursorShape.CrossCursor + + def set_draw_mode(self, enabled: bool): + """Enable or disable draw mode""" + self.draw_mode_enabled = enabled + + if enabled: + self.setCursor(self.draw_cursor) + else: + self.setCursor(self.default_cursor) + # Clean up any temporary drawing + if self.temp_rect_item: + self.scene().removeItem(self.temp_rect_item) + self.temp_rect_item = None + self.is_drawing = False + + def mousePressEvent(self, event: QtGui.QMouseEvent): + """Handle mouse press event for starting rectangle drawing""" + if self.draw_mode_enabled and event.button() == QtCore.Qt.MouseButton.LeftButton: + # Convert viewport coordinates to scene coordinates + scene_pos = self.mapToScene(event.pos()) + + # Start drawing + self.is_drawing = True + self.draw_start_point = scene_pos + self.draw_current_point = scene_pos + + # Create temporary rectangle for visual feedback + pen = QtGui.QPen(QtGui.QColor(255, 100, 0)) # Orange for drawing + pen.setWidth(2) + pen.setStyle(QtCore.Qt.PenStyle.DashLine) + + self.temp_rect_item = self.scene().addRect( + scene_pos.x(), scene_pos.y(), 0, 0, + pen, QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush) + ) + # Make sure temp rect is drawn on top + self.temp_rect_item.setZValue(100) + + else: + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QtGui.QMouseEvent): + """Handle mouse move event for updating rectangle during drawing""" + if self.is_drawing and self.draw_mode_enabled: + # Update current point + scene_pos = self.mapToScene(event.pos()) + self.draw_current_point = scene_pos + + # Update temporary rectangle + if self.temp_rect_item and self.draw_start_point: + x = min(self.draw_start_point.x(), self.draw_current_point.x()) + y = min(self.draw_start_point.y(), self.draw_current_point.y()) + width = abs(self.draw_current_point.x() - self.draw_start_point.x()) + height = abs(self.draw_current_point.y() - self.draw_start_point.y()) + + self.temp_rect_item.setRect(x, y, width, height) + else: + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QtGui.QMouseEvent): + """Handle mouse release event for finishing rectangle drawing""" + if self.is_drawing and self.draw_mode_enabled and event.button() == QtCore.Qt.MouseButton.LeftButton: + # Finish drawing + scene_pos = self.mapToScene(event.pos()) + self.draw_current_point = scene_pos + + # Calculate rectangle in scene coordinates + if self.draw_start_point: + x_start_pixels = self.draw_start_point.x() + y_start_pixels = self.draw_start_point.y() + x_end_pixels = self.draw_current_point.x() + y_end_pixels = self.draw_current_point.y() + + # Convert to mm coordinates relative to scene center (which is optical axis at 0,0) + # Scene uses Qt coordinates (Y+ down), so negate Y to get Cartesian (Y+ up) + # Then add optical axis offset to get stage coordinates + x_start_mm = (x_start_pixels / self.pixels_per_mm) + 55.0 + y_start_mm = (-y_start_pixels / self.pixels_per_mm) + 35.0 + x_end_mm = (x_end_pixels / self.pixels_per_mm) + 55.0 + y_end_mm = (-y_end_pixels / self.pixels_per_mm) + 35.0 + + # Calculate delta + x_delta_mm = x_end_mm - x_start_mm + y_delta_mm = y_end_mm - y_start_mm + + # Only emit if the rectangle has some size + if abs(x_delta_mm) > 0.1 and abs(y_delta_mm) > 0.1: # At least 0.1mm + # Emit signal with the coordinates + self.rectangle_drawn.emit(x_start_mm, y_start_mm, x_delta_mm, y_delta_mm) + + # Clean up temporary rectangle + if self.temp_rect_item: + self.scene().removeItem(self.temp_rect_item) + self.temp_rect_item = None + + # Reset drawing state + self.is_drawing = False + self.draw_start_point = None + self.draw_current_point = None + + else: + super().mouseReleaseEvent(event) + + +class MainLauncher(QtWidgets.QMainWindow): + """Main launcher window for Scanengine 3""" + + def __init__(self): + super().__init__() + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), 'main_launcher.ui') + uic.loadUi(ui_path, self) + + # Store reference to wizard window + self.wizard_window = None + self.options_dialog = None + + # Connect signals to slots + self.setup_connections() + + def setup_connections(self): + """Connect UI controls to their event handlers""" + self.pb_start_new_scan.clicked.connect(self.on_start_new_scan_clicked) + self.pb_continue_scan.clicked.connect(self.on_continue_scan_clicked) + self.pb_open_options.clicked.connect(self.on_open_options_clicked) + + def on_start_new_scan_clicked(self): + """Handle 'Begin a New Scan' button click""" + print("Starting new scan...") + # Hide the main launcher + self.hide() + # Show the wizard + self.wizard_window = NewScanWizard(parent_launcher=self) + self.wizard_window.show() + + def on_continue_scan_clicked(self): + """Handle 'Continue an Existing Scan' button click (stub)""" + print("Continue existing scan - Not implemented yet") + + def on_open_options_clicked(self): + """Handle 'Configure System / Set Default Values' button click""" + print("Opening options dialog...") + self.options_dialog = OptionsDialog(self) + self.options_dialog.exec() + + +class NewScanWizard(QtWidgets.QWidget): + """Wizard for creating a new scan""" + + def __init__(self, parent_launcher=None): + super().__init__() + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), 'new_scan_wizard.ui') + uic.loadUi(ui_path, self) + + # Replace the graphicsView with our custom ScanGraphicsView + # Store the old widget's properties + old_graphics_view = self.graphicsView + parent_widget = old_graphics_view.parent() + layout_item = self.gridLayout_3.itemAtPosition(6, 2) + + # Create our custom graphics view + self.graphicsView = ScanGraphicsView(parent_widget) + self.graphicsView.setObjectName("graphicsView") + self.graphicsView.setMinimumSize(old_graphics_view.minimumSize()) + self.graphicsView.setMaximumSize(old_graphics_view.maximumSize()) + + # Replace in the layout + self.gridLayout_3.removeWidget(old_graphics_view) + old_graphics_view.deleteLater() + self.gridLayout_3.addWidget(self.graphicsView, 6, 2, 1, 2) + + self.parent_launcher = parent_launcher + + # Initialize camera components + self.camera = None + self.camera_stream_thread = None + self.ccd_scene = None + self.ccd_pixmap_item = None + + # Initialize scan visualization components + self.scan_scene = None + self.scan_circle_item = None + self.scan_crosshair_h = None + self.scan_crosshair_v = None + self.scan_box_item = None + self.scan_sample_circle_item = None # Sample holder circle + self.draw_mode_active = False + + # Optical axis position on stage (mm) + self.optical_axis_x = 55.0 + self.optical_axis_y = 35.0 + self.scan_pixels_per_mm = 6.0 + + # Timer for debouncing coordinate updates + self.scan_box_update_timer = QtCore.QTimer() + self.scan_box_update_timer.setSingleShot(True) + self.scan_box_update_timer.setInterval(300) # 300ms delay + self.scan_box_update_timer.timeout.connect(self.update_scan_box_visualization) + + # Initialize Helios laser + self.helios_laser = None + self.helios_enabled = False + + # Initialize motion worker for stage position updates + self.motion_thread = QtCore.QThread() + self.motion_worker = MotionWorker() + self.motion_worker.moveToThread(self.motion_thread) + self.motion_thread.started.connect(self.motion_worker.run) + self.motion_thread.start() + + # Auto-connect to stage controller + QtCore.QTimer.singleShot(100, self.motion_worker.queue_connect) + + # Wobble mode state + self.wobble_active = False + self.wobble_timer = QtCore.QTimer(self) + self.wobble_timer.timeout.connect(self.on_wobble_timer) + self.wobble_direction = 1 # 1 for positive, -1 for negative + self.wobble_center_pos = 0.0 # Center position for wobble + self.wobble_speed = 10.0 # mm/s for wobble moves + self.wobble_axis = 'x' # Current wobble axis + + # Stage lock state (locked = motors enabled, unlocked = motors disabled for manual movement) + self.stage_locked = True + + # Initialize the camera + self.initialize_camera() + + # Initialize the scan visualization + self.initialize_scan_visualization() + + # Connect signals to slots + self.setup_connections() + + # Set the initial page to 0 (Step 1) + self.stackedWidget.setCurrentIndex(0) + + # Add sample size combobox to Step 3 + self.setup_sample_size_combobox() + + # Initialize UI state + self.initialize_ui_state() + + def setup_connections(self): + """Connect UI controls to their event handlers""" + # Wizard navigation buttons + self.btn_wiz_cancel.clicked.connect(self.on_cancel_clicked) + self.btn_wiz_back.clicked.connect(self.on_back_clicked) + self.btn_wiz_next.clicked.connect(self.on_next_clicked) + + # Connect page change signal to update button states + self.stackedWidget.currentChanged.connect(self.update_navigation_buttons) + + # Step 1: Metadata controls + self.le_scan_friendly_name.textChanged.connect(self.on_scan_friendly_name_changed) + self.le_data_dir.textChanged.connect(self.on_data_dir_changed) + self.btn_browse_dir.clicked.connect(self.on_browse_dir_clicked) + self.le_waveform_prefix.textChanged.connect(self.on_waveform_prefix_changed) + self.cb_number_of_angles.currentIndexChanged.connect(self.on_number_of_angles_changed) + self.le_row_spacing.textChanged.connect(self.on_row_spacing_changed) + self.rdo_standalone_mode.toggled.connect(self.on_standalone_mode_toggled) + self.rdo_coop_mode.toggled.connect(self.on_coop_mode_toggled) + self.le_numcycles_coop.textChanged.connect(self.on_numcycles_coop_changed) + + # Step 2: Focus/Alignment controls + self.btn_x_axis_mode.toggled.connect(self.on_x_axis_mode_toggled) + self.btn_y_axis_mode.toggled.connect(self.on_y_axis_mode_toggled) + self.btn_jog_up_a.clicked.connect(self.on_jog_up_a_clicked) + self.btn_jog_down_a.clicked.connect(self.on_jog_down_a_clicked) + self.btn_jog_up_b.clicked.connect(self.on_jog_up_b_clicked) + self.btn_jog_down_b.clicked.connect(self.on_jog_down_b_clicked) + self.le_wobble_distance.textChanged.connect(self.on_wobble_distance_changed) + self.btn_toggle_wobble_mode.toggled.connect(self.on_toggle_wobble_mode_toggled) + self.btn_toggle_stage_lock.clicked.connect(self.on_toggle_stage_lock_clicked) + + # Step 2: Camera controls + self.slider_exposure.valueChanged.connect(self.on_exposure_slider_changed) + self.slider_gain.valueChanged.connect(self.on_gain_slider_changed) + + # Step 2: Helios laser control + self.btn_toggle_helios.toggled.connect(self.on_helios_toggle) + + # Step 2: Stage jogging + self.btn_jog_stage.clicked.connect(self.on_jog_stage_clicked) + + # Step 2: Motion worker signals + self.motion_worker.position_updated.connect(self.on_stage_position_updated) + + # Step 3: Define Scan controls + self.le_x_start_coord.textChanged.connect(self.on_x_start_coord_changed) + self.le_x_delta_coord.textChanged.connect(self.on_x_delta_coord_changed) + self.le_y_start_coord.textChanged.connect(self.on_y_start_coord_changed) + self.le_y_delta_coord.textChanged.connect(self.on_y_delta_coord_changed) + self.btn_draw_scan_mode.clicked.connect(self.on_draw_scan_mode_clicked) + self.btn_clear_bounds.clicked.connect(self.on_clear_bounds_clicked) + self.btn_do_lowres_scan.clicked.connect(self.on_do_lowres_scan_clicked) + self.btn_finer_survey.clicked.connect(self.on_finer_survey_clicked) + + # Step 4: Summary controls + self.pushButton.clicked.connect(self.on_start_scanning_clicked) + + def setup_sample_size_combobox(self): + """Add sample size combobox to Step 3""" + # Create label + self.label_sample_size = QtWidgets.QLabel("Sample Size:") + self.label_sample_size.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) + + # Create combobox + self.combo_sample_size = QtWidgets.QComboBox() + self.combo_sample_size.addItem("1.25\"", 31.75) # 1.25" = 31.75mm + self.combo_sample_size.addItem("40mm", 40.0) + self.combo_sample_size.setMaximumWidth(100) + + # Add to layout (row 2, columns 2-3, next to X-Start) + self.gridLayout_3.addWidget(self.label_sample_size, 2, 2, QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) + self.gridLayout_3.addWidget(self.combo_sample_size, 2, 3, QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter) + + # Connect signal + self.combo_sample_size.currentIndexChanged.connect(self.on_sample_size_changed) + + def initialize_ui_state(self): + """Initialize UI state on startup""" + # Populate number of angles combobox (1-18) + for i in range(1, 19): + self.cb_number_of_angles.addItem(str(i)) + # Set default to 4 angles + self.cb_number_of_angles.setCurrentIndex(3) # Index 3 = "4" + + # Hide cooperative mode controls initially (standalone is default) + self.labl_numcycles_coop.setVisible(False) + self.le_numcycles_coop.setVisible(False) + + # Set standalone mode as default + self.rdo_standalone_mode.setChecked(True) + + # Set X axis alignment mode as default (mutually exclusive with Y) + self.btn_x_axis_mode.setChecked(True) + self.btn_y_axis_mode.setChecked(False) + + # Update navigation button states for initial page + self.update_navigation_buttons(0) + + def initialize_camera(self): + """Initialize the uC480 camera and set up the graphics view""" + try: + # Create camera instance + self.camera = UC480Camera(camera_id=0) + + # Initialize the camera + if not self.camera.initialize(): + print("Warning: Failed to initialize camera. Camera features will be disabled.") + self.camera = None + return + + # Create graphics scene for displaying camera frames + self.ccd_scene = QtWidgets.QGraphicsScene() + self.ccdGraphicsView.setScene(self.ccd_scene) + + # Create a pixmap item for the camera frame + self.ccd_pixmap_item = QtWidgets.QGraphicsPixmapItem() + self.ccd_scene.addItem(self.ccd_pixmap_item) + + # Create camera stream thread + self.camera_stream_thread = CameraStreamThread(self.camera) + self.camera_stream_thread.frame_ready.connect(self.on_camera_frame_ready) + self.camera_stream_thread.error_occurred.connect(self.on_camera_error) + + print(f"Camera initialized successfully: {self.camera.get_sensor_info()}") + + except Exception as e: + print(f"Error initializing camera: {e}") + self.camera = None + + def on_camera_frame_ready(self, frame: QtGui.QImage): + """Handle new camera frame and display it in the graphics view""" + if self.ccd_pixmap_item is not None: + # Convert QImage to QPixmap and display + pixmap = QtGui.QPixmap.fromImage(frame) + + # Scale to fit the graphics view while maintaining aspect ratio + view_size = self.ccdGraphicsView.size() + scaled_pixmap = pixmap.scaled( + view_size.width() - 10, + view_size.height() - 10, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation + ) + + self.ccd_pixmap_item.setPixmap(scaled_pixmap) + + # Center the image in the view + self.ccd_scene.setSceneRect(QtCore.QRectF(scaled_pixmap.rect())) + + def on_camera_error(self, error_msg: str): + """Handle camera errors""" + print(f"Camera error: {error_msg}") + + def initialize_scan_visualization(self): + """Initialize the scan visualization graphics view on Step 3""" + try: + # Create graphics scene for scan visualization + self.scan_scene = QtWidgets.QGraphicsScene() + self.graphicsView.setScene(self.scan_scene) + + # Set scene size to match 50mm diameter circle + # We'll use a scale of 6 pixels per mm for good resolution + pixels_per_mm = 6.0 + diameter_mm = 50.0 + scene_size = diameter_mm * pixels_per_mm # 300 pixels + + # Optical axis is at stage coordinates (55.0, 35.0) + self.optical_axis_x = 55.0 # mm + self.optical_axis_y = 35.0 # mm + + self.scan_scene.setSceneRect(-scene_size/2, -scene_size/2, scene_size, scene_size) + + # Draw the 50mm diameter circle (working area boundary) + pen = QtGui.QPen(QtGui.QColor(100, 100, 100)) # Gray + pen.setWidth(2) + radius = scene_size / 2 + self.scan_circle_item = self.scan_scene.addEllipse( + -radius, -radius, 2*radius, 2*radius, + pen, QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush) + ) + + # Draw sample holder circle (initially for 1.25" = 31.75mm) + sample_pen = QtGui.QPen(QtGui.QColor(140, 140, 140)) # Darker gray + sample_pen.setWidth(1) + sample_diameter_mm = 31.75 # Default to 1.25" + sample_radius_pixels = (sample_diameter_mm / 2.0) * pixels_per_mm + self.scan_sample_circle_item = self.scan_scene.addEllipse( + -sample_radius_pixels, -sample_radius_pixels, + 2*sample_radius_pixels, 2*sample_radius_pixels, + sample_pen, QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush) + ) + + # Draw crosshair at center (optical axis) + crosshair_pen = QtGui.QPen(QtGui.QColor(0, 0, 0)) # Black + crosshair_pen.setWidth(1) + crosshair_size = 15 # pixels + + # Horizontal line + self.scan_crosshair_h = self.scan_scene.addLine( + -crosshair_size, 0, crosshair_size, 0, + crosshair_pen + ) + + # Vertical line + self.scan_crosshair_v = self.scan_scene.addLine( + 0, -crosshair_size, 0, crosshair_size, + crosshair_pen + ) + + # Create the scan box item (initially invisible) + scan_box_pen = QtGui.QPen(QtGui.QColor(255, 0, 0)) # Red + scan_box_pen.setWidth(2) + self.scan_box_item = self.scan_scene.addRect( + 0, 0, 1, 1, + scan_box_pen, QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush) + ) + self.scan_box_item.setVisible(False) + print(f"Scan box item created: {self.scan_box_item}") + + # Store pixels_per_mm for coordinate conversion + self.scan_pixels_per_mm = pixels_per_mm + print(f"Pixels per mm set to: {self.scan_pixels_per_mm}") + + # Set pixels_per_mm in the custom graphics view + self.graphicsView.pixels_per_mm = pixels_per_mm + + # Connect the rectangle_drawn signal from the custom graphics view + self.graphicsView.rectangle_drawn.connect(self.on_rectangle_drawn) + + print("Scan visualization initialized successfully") + + except Exception as e: + print(f"Error initializing scan visualization: {e}") + import traceback + traceback.print_exc() + + def start_camera_stream(self): + """Start the camera streaming thread""" + if self.camera_stream_thread and not self.camera_stream_thread.isRunning(): + print("Starting camera stream...") + self.camera_stream_thread.start() + + def stop_camera_stream(self): + """Stop the camera streaming thread""" + if self.camera_stream_thread and self.camera_stream_thread.isRunning(): + print("Stopping camera stream...") + self.camera_stream_thread.stop() + + def cleanup_camera(self): + """Clean up camera resources""" + self.stop_camera_stream() + if self.camera: + self.camera.cleanup() + self.camera = None + + def update_navigation_buttons(self, page_index): + """Update navigation button states based on current page""" + # Disable Back button on first page (index 0) + self.btn_wiz_back.setEnabled(page_index > 0) + + # Disable Next button on last page (index 3) + self.btn_wiz_next.setEnabled(page_index < 3) + + # Handle camera streaming based on page + # Page index 1 is Step_2_Focus (the alignment page with ccdGraphicsView) + if page_index == 1: + # Start camera streaming when entering the focus/alignment page + self.start_camera_stream() + else: + # Stop camera streaming when leaving the focus/alignment page + self.stop_camera_stream() + + # Disable draw mode when leaving page 2 (Step_3_Define_Scan) + if page_index != 2 and self.draw_mode_active: + self.draw_mode_active = False + self.btn_draw_scan_mode.setText("Draw Mode") + self.btn_draw_scan_mode.setStyleSheet("") + self.graphicsView.set_draw_mode(False) + + # Update summary when entering page 3 (Step_4_Summary) + if page_index == 3: + self.update_summary_page() + + # ===== Wizard Navigation ===== + def closeEvent(self, event): + """Handle window close event - clean up camera, laser, and wobble resources""" + # Stop wobble mode if active + if self.wobble_active: + self.stop_wobble_mode() + + # Re-enable motors if stage was unlocked (so it doesn't stay in manual mode) + if not self.stage_locked and self.motion_worker.is_connected: + self.motion_worker.queue_set_axis_enable('x', True) + self.motion_worker.queue_set_axis_enable('y', True) + + self.cleanup_camera() + + # Disable and disconnect Helios laser + if self.helios_laser: + self.disable_helios() + self.helios_laser.disconnect() + + # Stop motion worker thread + if self.motion_worker: + self.motion_worker.stop() + if self.motion_thread: + self.motion_thread.quit() + self.motion_thread.wait(5000) # Wait up to 5 seconds (position requests can take 1.5s each) + + super().closeEvent(event) + + def on_cancel_clicked(self): + """Handle wizard Cancel button""" + print("Wizard cancelled") + self.close() + if self.parent_launcher: + self.parent_launcher.show() + + def on_back_clicked(self): + """Handle wizard Back button""" + current_index = self.stackedWidget.currentIndex() + if current_index > 0: + self.stackedWidget.setCurrentIndex(current_index - 1) + print(f"Navigated to page {current_index - 1}") + + def on_next_clicked(self): + """Handle wizard Next button""" + current_index = self.stackedWidget.currentIndex() + + # Validate before allowing navigation + if current_index == 0: # Step 1: Metadata + if not self.validate_step1(): + return + elif current_index == 2: # Step 3: Define Scan + if not self.validate_step3(): + return + + if current_index < self.stackedWidget.count() - 1: + self.stackedWidget.setCurrentIndex(current_index + 1) + print(f"Navigated to page {current_index + 1}") + + # ===== Validation Methods ===== + def validate_step1(self) -> bool: + """Validate Step 1 (Metadata) before allowing user to proceed""" + errors = [] + + # Scan Friendly Name + friendly_name = self.le_scan_friendly_name.text().strip() + if not friendly_name: + errors.append("• Scan Friendly Name is required") + + # Data Directory + data_dir = self.le_data_dir.text().strip() + if not data_dir: + errors.append("• Data Directory is required") + elif not os.path.isdir(data_dir): + errors.append("• Data Directory does not exist") + + # Waveform Prefix + waveform_prefix = self.le_waveform_prefix.text().strip() + if not waveform_prefix: + errors.append("• Waveform File Prefix is required") + elif not waveform_prefix.replace('_', '').replace('-', '').isalnum(): + errors.append("• Waveform Prefix must contain only letters, numbers, hyphens, and underscores") + + # Row Spacing + row_spacing = self.le_row_spacing.text().strip() + if not row_spacing: + errors.append("• Row Spacing is required") + else: + try: + spacing = float(row_spacing) + if spacing <= 0: + errors.append("• Row Spacing must be greater than 0") + elif spacing > 50: + errors.append("• Row Spacing must be 50mm or less") + except ValueError: + errors.append("• Row Spacing must be a valid number") + + # Cooperative mode validation + if self.rdo_coop_mode.isChecked(): + num_cycles = self.le_numcycles_coop.text().strip() + if not num_cycles: + errors.append("• Number of Cycles/Layers is required for Cooperative mode") + else: + try: + cycles = int(num_cycles) + if cycles <= 0: + errors.append("• Number of Cycles must be greater than 0") + elif cycles > 100: + errors.append("• Number of Cycles must be 100 or less") + except ValueError: + errors.append("• Number of Cycles must be a valid integer") + + # Show errors if any + if errors: + QtWidgets.QMessageBox.warning( + self, + "Validation Error", + "Please correct the following errors before proceeding:\n\n" + "\n".join(errors) + ) + return False + + return True + + def validate_step3(self) -> bool: + """Validate Step 3 (Define Scan) before allowing user to proceed""" + errors = [] + + # X Start + x_start = self.le_x_start_coord.text().strip() + if not x_start: + errors.append("• X-Start coordinate is required") + else: + try: + x_s = float(x_start) + if x_s < 0 or x_s > 110: + errors.append("• X-Start must be between 0 and 110mm") + except ValueError: + errors.append("• X-Start must be a valid number") + + # X Delta + x_delta = self.le_x_delta_coord.text().strip() + if not x_delta: + errors.append("• X-Delta coordinate is required") + else: + try: + x_d = float(x_delta) + if x_d == 0: + errors.append("• X-Delta cannot be 0") + elif abs(x_d) > 50: + errors.append("• X-Delta must be 50mm or less in magnitude") + except ValueError: + errors.append("• X-Delta must be a valid number") + + # Y Start + y_start = self.le_y_start_coord.text().strip() + if not y_start: + errors.append("• Y-Start coordinate is required") + else: + try: + y_s = float(y_start) + if y_s < 0 or y_s > 70: + errors.append("• Y-Start must be between 0 and 70mm") + except ValueError: + errors.append("• Y-Start must be a valid number") + + # Y Delta + y_delta = self.le_y_delta_coord.text().strip() + if not y_delta: + errors.append("• Y-Delta coordinate is required") + else: + try: + y_d = float(y_delta) + if y_d == 0: + errors.append("• Y-Delta cannot be 0") + elif abs(y_d) > 50: + errors.append("• Y-Delta must be 50mm or less in magnitude") + except ValueError: + errors.append("• Y-Delta must be a valid number") + + # Validate scan area is within bounds + if not errors: # Only check if individual coords are valid + try: + x_s = float(x_start) + x_d = float(x_delta) + y_s = float(y_start) + y_d = float(y_delta) + + x_end = x_s + x_d + y_end = y_s + y_d + + if x_end < 0 or x_end > 110: + errors.append(f"• X-End ({x_end:.2f}mm) must be between 0 and 110mm") + if y_end < 0 or y_end > 70: + errors.append(f"• Y-End ({y_end:.2f}mm) must be between 0 and 70mm") + except ValueError: + pass # Already reported above + + # Show errors if any + if errors: + QtWidgets.QMessageBox.warning( + self, + "Validation Error", + "Please correct the following errors before proceeding:\n\n" + "\n".join(errors) + ) + return False + + return True + + # ===== Step 1: Metadata Event Handlers ===== + def on_scan_friendly_name_changed(self, text): + """Handle scan friendly name text change""" + print(f"Scan friendly name changed: {text}") + + def on_data_dir_changed(self, text): + """Handle data directory text change""" + print(f"Data directory changed: {text}") + + def on_browse_dir_clicked(self): + """Handle browse directory button click""" + print("Browse for directory") + directory = QtWidgets.QFileDialog.getExistingDirectory( + self, "Select Data Directory", "" + ) + if directory: + self.le_data_dir.setText(directory) + + def on_waveform_prefix_changed(self, text): + """Handle waveform prefix text change""" + print(f"Waveform prefix changed: {text}") + + def on_number_of_angles_changed(self, index): + """Handle number of angles combobox change""" + print(f"Number of angles changed to index: {index}") + + def on_row_spacing_changed(self, text): + """Handle row spacing text change""" + print(f"Row spacing changed: {text}") + + def on_standalone_mode_toggled(self, checked): + """Handle standalone mode radio button toggle""" + print(f"Standalone mode toggled: {checked}") + # Show/hide cooperative mode controls + if checked: + self.labl_numcycles_coop.setVisible(False) + self.le_numcycles_coop.setVisible(False) + + def on_coop_mode_toggled(self, checked): + """Handle cooperative mode radio button toggle""" + print(f"Cooperative mode toggled: {checked}") + # Show/hide cooperative mode controls + if checked: + self.labl_numcycles_coop.setVisible(True) + self.le_numcycles_coop.setVisible(True) + + def on_numcycles_coop_changed(self, text): + """Handle number of cycles/layers text change""" + print(f"Number of cycles changed: {text}") + + # ===== Step 2: Focus/Alignment Event Handlers ===== + def on_x_axis_mode_toggled(self, checked): + """Handle X axis mode toggle - mutually exclusive with Y axis""" + if checked: + print("Switched to X axis alignment mode") + # Uncheck Y axis button (without triggering its handler recursively) + self.btn_y_axis_mode.blockSignals(True) + self.btn_y_axis_mode.setChecked(False) + self.btn_y_axis_mode.blockSignals(False) + elif not self.btn_y_axis_mode.isChecked(): + # Don't allow unchecking if Y is also unchecked - keep X checked + self.btn_x_axis_mode.blockSignals(True) + self.btn_x_axis_mode.setChecked(True) + self.btn_x_axis_mode.blockSignals(False) + + def on_y_axis_mode_toggled(self, checked): + """Handle Y axis mode toggle - mutually exclusive with X axis""" + if checked: + print("Switched to Y axis alignment mode") + # Uncheck X axis button (without triggering its handler recursively) + self.btn_x_axis_mode.blockSignals(True) + self.btn_x_axis_mode.setChecked(False) + self.btn_x_axis_mode.blockSignals(False) + elif not self.btn_x_axis_mode.isChecked(): + # Don't allow unchecking if X is also unchecked - keep Y checked + self.btn_y_axis_mode.blockSignals(True) + self.btn_y_axis_mode.setChecked(True) + self.btn_y_axis_mode.blockSignals(False) + + def on_jog_up_a_clicked(self): + """Handle jog up axis A button click""" + print("Jog up axis A") + + def on_jog_down_a_clicked(self): + """Handle jog down axis A button click""" + print("Jog down axis A") + + def on_jog_up_b_clicked(self): + """Handle jog up axis B button click""" + print("Jog up axis B") + + def on_jog_down_b_clicked(self): + """Handle jog down axis B button click""" + print("Jog down axis B") + + def on_wobble_distance_changed(self, text): + """Handle wobble distance text change""" + print(f"Wobble distance changed: {text}") + + def on_toggle_wobble_mode_toggled(self, checked): + """Handle toggle wobble mode button""" + print(f"Wobble mode toggled: {checked}") + + if checked: + self.start_wobble_mode() + else: + self.stop_wobble_mode() + + def start_wobble_mode(self): + """Start wobble mode - oscillate stage along selected axis""" + if not self.motion_worker.is_connected: + print("Cannot start wobble - stage not connected") + self.btn_toggle_wobble_mode.setChecked(False) + return + + # Get wobble distance + try: + wobble_distance = float(self.le_wobble_distance.text()) + if wobble_distance <= 0: + print("Invalid wobble distance") + self.btn_toggle_wobble_mode.setChecked(False) + return + except ValueError: + print("Invalid wobble distance value") + self.btn_toggle_wobble_mode.setChecked(False) + return + + # Determine which axis to wobble based on toggle button state + if self.btn_x_axis_mode.isChecked(): + self.wobble_axis = 'x' + # Get current X position as center + self.wobble_center_pos = self.motion_worker.last_x if self.motion_worker.last_x is not None else 0.0 + else: + self.wobble_axis = 'y' + # Get current Y position as center + self.wobble_center_pos = self.motion_worker.last_y if self.motion_worker.last_y is not None else 0.0 + + print(f"Starting wobble mode: axis={self.wobble_axis}, center={self.wobble_center_pos:.3f}mm, distance={wobble_distance}mm") + + # Set velocity for wobble speed (~10mm/s) + self.motion_worker.queue_set_velocity(self.wobble_speed, 50.0) + + # Set step size to wobble distance + self.motion_worker.queue_set_step_size(wobble_distance) + + self.wobble_active = True + self.wobble_direction = 1 # Start moving in positive direction + + # Start the first wobble move + self.motion_worker.queue_jog(self.wobble_axis, self.wobble_direction) + + # Calculate timer interval based on wobble distance and speed + # Time to complete one move = distance / speed, then convert to ms + move_time_ms = int((wobble_distance / self.wobble_speed) * 1000) + 100 # Add 100ms buffer + self.wobble_timer.start(move_time_ms) + + def stop_wobble_mode(self): + """Stop wobble mode""" + print("Stopping wobble mode") + self.wobble_active = False + self.wobble_timer.stop() + + # Restore default velocity and step size + self.motion_worker.queue_set_velocity(20.0, 50.0) + self.motion_worker.queue_set_step_size(1.0) + + def on_wobble_timer(self): + """Timer callback for wobble mode - reverse direction and move""" + if not self.wobble_active: + self.wobble_timer.stop() + return + + # Reverse direction + self.wobble_direction *= -1 + + # Queue the next wobble move + self.motion_worker.queue_jog(self.wobble_axis, self.wobble_direction) + + def on_toggle_stage_lock_clicked(self): + """Handle toggle stage lock button click - enable/disable motors for manual movement""" + print("Toggle stage lock clicked") + + if self.stage_locked: + # Currently locked -> Unlock (disable motors for manual movement) + self.unlock_stage() + else: + # Currently unlocked -> Lock (enable motors) + self.lock_stage() + + def unlock_stage(self): + """Unlock stage - disable motors so user can move stage by hand""" + if not self.motion_worker.is_connected: + print("Cannot unlock stage - not connected") + return + + print("Unlocking stage - disabling motors for manual movement") + + # Stop wobble if active + if self.wobble_active: + self.stop_wobble_mode() + self.btn_toggle_wobble_mode.setChecked(False) + + # Disable both axes + self.motion_worker.queue_set_axis_enable('x', False) + self.motion_worker.queue_set_axis_enable('y', False) + + self.stage_locked = False + self.btn_toggle_stage_lock.setText("Lock Stage") + + # Disable wobble button while unlocked + self.btn_toggle_wobble_mode.setEnabled(False) + + def lock_stage(self): + """Lock stage - re-enable motors""" + if not self.motion_worker.is_connected: + print("Cannot lock stage - not connected") + return + + print("Locking stage - enabling motors") + + # Re-enable both axes + self.motion_worker.queue_set_axis_enable('x', True) + self.motion_worker.queue_set_axis_enable('y', True) + + self.stage_locked = True + self.btn_toggle_stage_lock.setText("Unlock Stage (Disables Wobble)") + + # Re-enable wobble button + self.btn_toggle_wobble_mode.setEnabled(True) + + def on_exposure_slider_changed(self, value): + """Handle exposure slider value change""" + print(f"Exposure slider changed: {value} ms") + + # Update the label + self.label_exposure_value.setText(f"{value} ms") + + # Update camera exposure if camera is initialized + if self.camera and self.camera.is_initialized: + success = self.camera.set_exposure(float(value)) + if not success: + print(f"Warning: Failed to set camera exposure to {value} ms") + + def on_gain_slider_changed(self, value): + """Handle gain slider value change""" + print(f"Gain slider changed: {value}") + + # Update the label + self.label_gain_value.setText(f"{value}") + + # Update camera gain if camera is initialized + if self.camera and self.camera.is_initialized: + success = self.camera.set_gain(value) + if not success: + print(f"Warning: Failed to set camera gain to {value}") + + def on_helios_toggle(self, checked): + """Handle Helios laser toggle button""" + if checked: + # Show safety warning before enabling + reply = QtWidgets.QMessageBox.warning( + self, + "Laser Safety Warning", + "⚠️ WARNING: Laser Emission About to Occur! ⚠️\n\n" + "You are about to enable the Helios generation laser.\n" + "Laser emission will begin immediately.\n\n" + "• Ensure all safety interlocks are engaged\n" + "• Ensure proper laser safety eyewear is worn\n" + "• Ensure the laser path is clear\n\n" + "Do you want to proceed?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No + ) + + if reply != QtWidgets.QMessageBox.StandardButton.Yes: + # User cancelled, uncheck the button + self.btn_toggle_helios.setChecked(False) + return + + # Try to enable the laser + if self.enable_helios_focusing(): + print("Helios laser enabled in focusing mode") + self.btn_toggle_helios.setText("Disable Helios") + else: + print("Failed to enable Helios laser") + self.btn_toggle_helios.setChecked(False) + QtWidgets.QMessageBox.critical( + self, + "Laser Error", + "Failed to enable Helios laser.\n" + "Check that the laser is connected and configured properly." + ) + else: + # Disable the laser + if self.disable_helios(): + print("Helios laser disabled") + self.btn_toggle_helios.setText("Enable Helios (Focusing Mode)") + else: + print("Warning: Failed to cleanly disable Helios laser") + + def enable_helios_focusing(self) -> bool: + """ + Enable Helios laser in focusing mode (low power). + + Returns: + True if successful + """ + try: + # Load configuration + config = self.load_helios_config() + + # Initialize laser if not already done + if self.helios_laser is None: + com_port = config.get('com_port', '') + if not com_port: + print("Error: No Helios COM port configured") + return False + + self.helios_laser = HeliosLaser(port=com_port, timeout=1.0) + + # Connect if not connected + if not self.helios_laser.is_connected: + if not self.helios_laser.connect(): + print("Error: Failed to connect to Helios laser") + return False + + # Get focusing parameters from config + focusing_freq = int(config.get('focusing_frequency_hz', 20000)) + focusing_current = int(config.get('focusing_pump_current_ma', 300)) + + print(f"Setting Helios to focusing mode: {focusing_freq} Hz, {focusing_current} mA") + + # Configure laser for focusing + if not self.helios_laser.set_frequency_hz(focusing_freq): + print("Error: Failed to set Helios frequency") + return False + + if not self.helios_laser.set_current_ma(focusing_current): + print("Error: Failed to set Helios current") + return False + + if not self.helios_laser.set_pulse_mode(PulseMode.CONTINUOUS_PULSING): + print("Error: Failed to set Helios pulse mode") + return False + + # Enable laser + if not self.helios_laser.set_laser_enable(True): + print("Error: Failed to enable Helios laser") + return False + + self.helios_enabled = True + return True + + except Exception as e: + print(f"Exception enabling Helios laser: {e}") + return False + + def disable_helios(self) -> bool: + """ + Disable Helios laser. + + Returns: + True if successful + """ + try: + if self.helios_laser and self.helios_laser.is_connected: + success = self.helios_laser.set_laser_enable(False) + self.helios_enabled = False + return success + return True + + except Exception as e: + print(f"Exception disabling Helios laser: {e}") + return False + + def load_helios_config(self) -> dict: + """ + Load Helios configuration from config.json. + + Returns: + Dictionary with Helios configuration + """ + try: + config_file = os.path.join(os.path.dirname(__file__), '..', 'config.json') + with open(config_file, 'r') as f: + config = json.load(f) + return config.get('generation_laser', {}) + except Exception as e: + print(f"Error loading Helios config: {e}") + # Return defaults + return { + 'com_port': '', + 'focusing_frequency_hz': 20000, + 'focusing_pump_current_ma': 300, + 'frequency_hz': 20000, + 'pump_diode_current_ma': 500 + } + + def on_jog_stage_clicked(self): + """Handle jog stage button click""" + print("Opening stage jogging dialog...") + + # Create and show the jog stage dialog, sharing our motion worker + dialog = JogStageDialog(self, shared_motion_worker=self.motion_worker) + dialog.exec() + + def on_stage_position_updated(self, x: float, y: float): + """Handle stage position update from motion worker""" + # Update the coordinate labels on Step 2 + self.stage_x_coordinate.setText(f"{x:.3f} mm") + self.stage_y_coordinate.setText(f"{y:.3f} mm") + + # ===== Step 3: Define Scan Event Handlers ===== + def on_x_start_coord_changed(self, text): + """Handle X start coordinate text change""" + print(f"X start coordinate changed: {text}") + # Debounce the update to avoid flickering during typing + self.scan_box_update_timer.start() + + def on_x_delta_coord_changed(self, text): + """Handle X delta coordinate text change""" + print(f"X delta coordinate changed: {text}") + # Debounce the update to avoid flickering during typing + self.scan_box_update_timer.start() + + def on_y_start_coord_changed(self, text): + """Handle Y start coordinate text change""" + print(f"Y start coordinate changed: {text}") + # Debounce the update to avoid flickering during typing + self.scan_box_update_timer.start() + + def on_y_delta_coord_changed(self, text): + """Handle Y delta coordinate text change""" + print(f"Y delta coordinate changed: {text}") + # Debounce the update to avoid flickering during typing + self.scan_box_update_timer.start() + + def on_sample_size_changed(self, index): + """Handle sample size combobox change""" + # Get diameter in mm from combobox item data + diameter_mm = self.combo_sample_size.itemData(index) + print(f"Sample size changed to {self.combo_sample_size.itemText(index)} ({diameter_mm}mm)") + + # Update the sample circle + if self.scan_sample_circle_item: + sample_radius_pixels = (diameter_mm / 2.0) * self.scan_pixels_per_mm + self.scan_sample_circle_item.setRect( + -sample_radius_pixels, -sample_radius_pixels, + 2*sample_radius_pixels, 2*sample_radius_pixels + ) + + def update_scan_box_visualization(self): + """Update the red scan box visualization based on coordinate inputs""" + if not self.scan_box_item: + print("Warning: scan_box_item is not initialized") + return + + if not hasattr(self, 'scan_pixels_per_mm'): + print("Warning: scan_pixels_per_mm is not initialized") + return + + try: + # Get coordinate values from input fields + x_start_text = self.le_x_start_coord.text().strip() + x_delta_text = self.le_x_delta_coord.text().strip() + y_start_text = self.le_y_start_coord.text().strip() + y_delta_text = self.le_y_delta_coord.text().strip() + + print(f"Coordinate inputs - X-Start: '{x_start_text}', X-Delta: '{x_delta_text}', Y-Start: '{y_start_text}', Y-Delta: '{y_delta_text}'") + + # Check if all fields have valid values + if not all([x_start_text, x_delta_text, y_start_text, y_delta_text]): + # Hide box if any field is empty + print("One or more fields are empty - hiding scan box") + self.scan_box_item.setVisible(False) + return + + # Parse values + x_start = float(x_start_text) + x_delta = float(x_delta_text) + y_start = float(y_start_text) + y_delta = float(y_delta_text) + + # Calculate end coordinates + x_end = x_start + x_delta + y_end = y_start + y_delta + + # Convert mm coordinates (which are in stage coordinates) to scene pixels + # Scene coordinates have (0,0) at optical axis (55.0, 35.0 in stage coords) + # Subtract optical axis offset to get coordinates relative to scene center + # First calculate in Cartesian coordinates (Y+ is up) + x_start_pixels = (x_start - self.optical_axis_x) * self.scan_pixels_per_mm + x_end_pixels = (x_end - self.optical_axis_x) * self.scan_pixels_per_mm + y_start_pixels_cartesian = (y_start - self.optical_axis_y) * self.scan_pixels_per_mm + y_end_pixels_cartesian = (y_end - self.optical_axis_y) * self.scan_pixels_per_mm + + # Convert to Qt coordinates (Y+ is down, so negate Y) + y_start_qt = -y_start_pixels_cartesian + y_end_qt = -y_end_pixels_cartesian + + # Calculate rectangle dimensions + # Use min/max to handle negative deltas correctly + left = min(x_start_pixels, x_end_pixels) + top = min(y_start_qt, y_end_qt) + width = abs(x_end_pixels - x_start_pixels) + height = abs(y_end_qt - y_start_qt) + + print(f"Scan box rectangle: left={left:.1f}, top={top:.1f}, width={width:.1f}, height={height:.1f}") + + # Update the scan box rectangle + self.scan_box_item.setRect(left, top, width, height) + self.scan_box_item.setVisible(True) + + print(f"Scan box updated and made visible: ({x_start:.2f}, {y_start:.2f}) to ({x_end:.2f}, {y_end:.2f}) mm") + + except ValueError as e: + # Invalid input - hide the box + print(f"ValueError parsing coordinates: {e}") + self.scan_box_item.setVisible(False) + except Exception as e: + print(f"Error updating scan box visualization: {e}") + import traceback + traceback.print_exc() + self.scan_box_item.setVisible(False) + + def on_rectangle_drawn(self, x_start: float, y_start: float, x_delta: float, y_delta: float): + """Handle rectangle drawn by user in draw mode""" + print(f"Rectangle drawn: start=({x_start:.2f}, {y_start:.2f}), delta=({x_delta:.2f}, {y_delta:.2f})") + + # Temporarily disconnect signals to avoid flickering during batch update + self.le_x_start_coord.textChanged.disconnect(self.on_x_start_coord_changed) + self.le_x_delta_coord.textChanged.disconnect(self.on_x_delta_coord_changed) + self.le_y_start_coord.textChanged.disconnect(self.on_y_start_coord_changed) + self.le_y_delta_coord.textChanged.disconnect(self.on_y_delta_coord_changed) + + # Update the coordinate input fields + self.le_x_start_coord.setText(f"{x_start:.2f}") + self.le_y_start_coord.setText(f"{y_start:.2f}") + self.le_x_delta_coord.setText(f"{x_delta:.2f}") + self.le_y_delta_coord.setText(f"{y_delta:.2f}") + + # Reconnect signals + self.le_x_start_coord.textChanged.connect(self.on_x_start_coord_changed) + self.le_x_delta_coord.textChanged.connect(self.on_x_delta_coord_changed) + self.le_y_start_coord.textChanged.connect(self.on_y_start_coord_changed) + self.le_y_delta_coord.textChanged.connect(self.on_y_delta_coord_changed) + + # Update visualization immediately (not debounced for draw mode) + self.update_scan_box_visualization() + + def on_draw_scan_mode_clicked(self): + """Handle draw scan mode button click""" + # Toggle draw mode + self.draw_mode_active = not self.draw_mode_active + + # Update button text and style + if self.draw_mode_active: + self.btn_draw_scan_mode.setText("Exit Draw Mode") + self.btn_draw_scan_mode.setStyleSheet("background-color: #ff6600; color: white; font-weight: bold;") + print("Draw mode enabled - click and drag to define scan area") + else: + self.btn_draw_scan_mode.setText("Draw Mode") + self.btn_draw_scan_mode.setStyleSheet("") + print("Draw mode disabled") + + # Enable/disable draw mode in the graphics view + self.graphicsView.set_draw_mode(self.draw_mode_active) + + def on_clear_bounds_clicked(self): + """Handle clear bounds button click""" + print("Clear bounds clicked") + + # Clear all coordinate input fields + self.le_x_start_coord.clear() + self.le_x_delta_coord.clear() + self.le_y_start_coord.clear() + self.le_y_delta_coord.clear() + + # Hide the scan box + if self.scan_box_item: + self.scan_box_item.setVisible(False) + + def on_do_lowres_scan_clicked(self): + """Handle do low-res scan button click""" + print("Do low-res scan clicked") + + def on_finer_survey_clicked(self): + """Handle finer survey button click""" + print("Finer survey clicked") + + # ===== Step 4: Summary Event Handlers ===== + def update_summary_page(self): + """Update the summary page with information from previous steps""" + # Scan Friendly Name + friendly_name = self.le_scan_friendly_name.text().strip() + if friendly_name: + self.lbl_friendlyname.setText(friendly_name) + else: + self.lbl_friendlyname.setText("") + + # Scan Coordinates + x_start = self.le_x_start_coord.text().strip() + x_delta = self.le_x_delta_coord.text().strip() + y_start = self.le_y_start_coord.text().strip() + y_delta = self.le_y_delta_coord.text().strip() + + if all([x_start, x_delta, y_start, y_delta]): + try: + x_s = float(x_start) + x_d = float(x_delta) + y_s = float(y_start) + y_d = float(y_delta) + x_end = x_s + x_d + y_end = y_s + y_d + coord_text = f"X: {x_s:.2f}mm to {x_end:.2f}mm, Y: {y_s:.2f}mm to {y_end:.2f}mm" + self.lbl_scan_coords.setText(coord_text) + + # Calculate scan area in mm² + area_mm2 = abs(x_d * y_d) + self.lbl_scan_area.setText(f"{area_mm2:.2f} mm²") + except ValueError: + self.lbl_scan_coords.setText("") + self.lbl_scan_area.setText("") + else: + self.lbl_scan_coords.setText("") + self.lbl_scan_area.setText("") + + # Row Spacing (Pixel Size) + row_spacing = self.le_row_spacing.text().strip() + if row_spacing: + try: + spacing = float(row_spacing) + self.lbl_pixel_size.setText(f"{spacing:.3f} mm") + except ValueError: + self.lbl_pixel_size.setText("") + else: + self.lbl_pixel_size.setText("") + + # Data Directory / Save Location + data_dir = self.le_data_dir.text().strip() + waveform_prefix = self.le_waveform_prefix.text().strip() + if data_dir and waveform_prefix: + self.lbl_scan_save_location.setText(f"{data_dir}/{waveform_prefix}_*.wfm") + elif data_dir: + self.lbl_scan_save_location.setText(data_dir) + else: + self.lbl_scan_save_location.setText("") + + # Build additional summary information + summary_parts = [] + + # Number of angles + num_angles_idx = self.cb_number_of_angles.currentIndex() + if num_angles_idx >= 0: + num_angles_text = self.cb_number_of_angles.currentText() + summary_parts.append(f"Angles: {num_angles_text}") + + # Scan type + if self.rdo_standalone_mode.isChecked(): + summary_parts.append("Type: Standalone") + elif self.rdo_coop_mode.isChecked(): + num_cycles = self.le_numcycles_coop.text().strip() + if num_cycles: + summary_parts.append(f"Type: Cooperative ({num_cycles} cycles)") + else: + summary_parts.append("Type: Cooperative") + + # Sample size + sample_size_text = self.combo_sample_size.currentText() + summary_parts.append(f"Sample: {sample_size_text}") + + # Display additional info in label_32 (currently empty label at row 10) + if summary_parts: + additional_info = " | ".join(summary_parts) + print(f"Summary additional info: {additional_info}") + + # Create a label for scan parameters if it doesn't exist + if not hasattr(self, 'lbl_scan_parameters'): + self.lbl_scan_parameters = QtWidgets.QLabel() + self.SummaryGridLayout.addWidget(QtWidgets.QLabel("Scan Parameters:"), 7, 0, + QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) + self.SummaryGridLayout.addWidget(self.lbl_scan_parameters, 7, 1) + + self.lbl_scan_parameters.setText(additional_info) + + def on_start_scanning_clicked(self): + """Handle start scanning button click""" + print("Start scanning clicked!") + + # Save metadata before starting scan + metadata_file = self.save_scan_metadata(scan_finished=False) + if not metadata_file: + QtWidgets.QMessageBox.critical( + self, + "Error", + "Failed to save scan metadata. Cannot start scan." + ) + return + + print(f"Scan metadata saved to: {metadata_file}") + + # Load the metadata to get scan boxes + try: + with open(metadata_file, 'r') as f: + metadata = json.load(f) + except Exception as e: + print(f"Error loading metadata: {e}") + return + + # Get sample diameter + sample_text = self.combo_sample_size.currentText() + if "1.25" in sample_text: + sample_diameter = 31.75 + else: + sample_diameter = 40.0 + + # Load scanning parameters from config + scan_velocity = 50.0 # Default mm/s + scan_acceleration_mm_s2 = 1500.0 # Default mm/s^2 + + try: + config_file = os.path.join(os.path.dirname(__file__), '..', 'config.json') + if os.path.exists(config_file): + with open(config_file, 'r') as f: + config = json.load(f) + scan_velocity = float(config.get('scanning_stage', {}).get('scan_velocity_mm_s', '50.0')) + scan_acceleration_mm_s2 = float(config.get('scanning_stage', {}).get('scan_acceleration_mm_s2', '1500.0')) + print(f"Loaded from config: velocity={scan_velocity} mm/s, acceleration={scan_acceleration_mm_s2} mm/s^2") + else: + print(f"Config file not found at {config_file}, using defaults: velocity={scan_velocity} mm/s, accel={scan_acceleration_mm_s2} mm/s^2") + except Exception as e: + print(f"Error loading scan parameters from config: {e}, using defaults: velocity={scan_velocity} mm/s, accel={scan_acceleration_mm_s2} mm/s^2") + + # Prepare scan parameters for visualization + scan_params = { + 'num_angles': metadata['scan_parameters']['number_of_angles'], + 'scan_boxes': metadata['scan_boxes'], + 'x_start': metadata['scan_area']['x_start_mm'], + 'x_delta': metadata['scan_area']['x_delta_mm'], + 'y_start': metadata['scan_area']['y_start_mm'], + 'y_delta': metadata['scan_area']['y_delta_mm'], + 'row_spacing': metadata['scan_parameters']['row_spacing_mm'], + 'sample_diameter_mm': sample_diameter, + 'scan_velocity_mm_s': scan_velocity, + 'scan_acceleration_mm_s2': scan_acceleration_mm_s2 # Already in mm/s^2 + } + + # Show visualization dialog (pass motion worker for stage control) + viz_dialog = ScanVisualizationDialog(self, scan_params, self.motion_worker) + viz_dialog.exec() + + # Store metadata file path for later update + self.current_scan_metadata_file = metadata_file + + # For now, just mark as finished when dialog closes + # In real implementation, this would be called after actual scanning + self.update_scan_metadata_finished() + + # Return to launcher + self.return_to_launcher() + + def demo_scan_progress(self, progress_dialog): + """ + Demonstrate the scan progress dialog. + This will be replaced with actual scanning logic. + """ + import time + + # Get scan parameters from the wizard + try: + num_angles = int(self.cb_number_of_angles.currentText()) + except (ValueError, AttributeError): + num_angles = 4 + + # Calculate number of rows (for demo purposes) + try: + y_delta = float(self.le_y_delta_coord.text()) + row_spacing = float(self.le_row_spacing.text()) + num_rows = max(1, int(abs(y_delta) / row_spacing)) + except (ValueError, AttributeError): + num_rows = 10 # Default for demo + + # Show the dialog + progress_dialog.show() + QtWidgets.QApplication.processEvents() + + # Simulate scanning + scan_was_cancelled = False + for scan_num in range(1, num_angles + 1): + if progress_dialog.is_cancelled(): + print("Scan cancelled by user") + scan_was_cancelled = True + break + + # Update total progress + progress_dialog.update_total_progress(scan_num, num_angles) + progress_dialog.reset_current_scan() + + # Simulate scanning rows + for row_num in range(1, num_rows + 1): + if progress_dialog.is_cancelled(): + print("Scan cancelled by user") + scan_was_cancelled = True + break + + # Update current scan progress + progress_dialog.update_current_scan(row_num, num_rows) + + # Process events to keep UI responsive + QtWidgets.QApplication.processEvents() + + # Simulate scan time (remove this in real implementation) + time.sleep(0.1) + + # Break outer loop if cancelled + if scan_was_cancelled: + break + + if scan_was_cancelled: + # Close the progress dialog + progress_dialog.close() + # Don't update metadata file - scan was cancelled + # Return to main launcher + self.return_to_launcher() + else: + # Mark scan as complete + progress_dialog.scan_complete() + print("Scan completed successfully!") + # Wait for user to close the dialog + progress_dialog.exec() + # Update metadata to mark scan as finished + self.update_scan_metadata_finished() + # After successful completion, also return to launcher + self.return_to_launcher() + + def return_to_launcher(self): + """Close the wizard and return to the main launcher""" + print("Returning to main launcher") + self.close() + if self.parent_launcher: + self.parent_launcher.show() + + def save_scan_metadata(self, scan_finished: bool = False) -> str: + """ + Save scan metadata to JSON file. + + Args: + scan_finished: Whether the scan has been completed + + Returns: + Path to the saved metadata file, or empty string on error + """ + try: + from datetime import datetime + import math + + # Get scan parameters + x_start = float(self.le_x_start_coord.text().strip()) + x_delta = float(self.le_x_delta_coord.text().strip()) + y_start = float(self.le_y_start_coord.text().strip()) + y_delta = float(self.le_y_delta_coord.text().strip()) + num_angles = int(self.cb_number_of_angles.currentText()) + + # Calculate scan boxes for each angle with rotation + scan_boxes = self.calculate_scan_boxes( + x_start, x_delta, y_start, y_delta, num_angles + ) + + # Collect metadata + metadata = { + "scan_info": { + "friendly_name": self.le_scan_friendly_name.text().strip(), + "waveform_prefix": self.le_waveform_prefix.text().strip(), + "data_directory": self.le_data_dir.text().strip(), + "timestamp": datetime.now().isoformat(), + "scan_finished": scan_finished + }, + "scan_parameters": { + "number_of_angles": num_angles, + "row_spacing_mm": float(self.le_row_spacing.text().strip()), + "scan_type": "standalone" if self.rdo_standalone_mode.isChecked() else "cooperative", + }, + "scan_area": { + "x_start_mm": x_start, + "x_delta_mm": x_delta, + "y_start_mm": y_start, + "y_delta_mm": y_delta, + "sample_size": self.combo_sample_size.currentText() + }, + "scan_boxes": scan_boxes + } + + # Add cooperative mode data if applicable + if self.rdo_coop_mode.isChecked(): + metadata["scan_parameters"]["num_cycles"] = int(self.le_numcycles_coop.text().strip()) + + # Generate filename: WFMPREFIX_TIME_DATE.json + prefix = self.le_waveform_prefix.text().strip() + timestamp = datetime.now() + time_str = timestamp.strftime("%H%M%S") + date_str = timestamp.strftime("%Y%m%d") + filename = f"{prefix}_{time_str}_{date_str}.json" + + # Full path + data_dir = self.le_data_dir.text().strip() + filepath = os.path.join(data_dir, filename) + + # Save to file + with open(filepath, 'w') as f: + json.dump(metadata, f, indent=2) + + return filepath + + except Exception as e: + print(f"Error saving scan metadata: {e}") + import traceback + traceback.print_exc() + return "" + + def calculate_scan_boxes(self, x_start: float, x_delta: float, + y_start: float, y_delta: float, + num_angles: int) -> list: + """ + Calculate scan boxes for each angle with CCW rotation. + + Args: + x_start: Starting X coordinate (mm) + x_delta: X extent (mm) + y_start: Starting Y coordinate (mm) + y_delta: Y extent (mm) + num_angles: Number of scan angles + + Returns: + List of scan box dictionaries with rotated coordinates + """ + import math + + # Calculate the bounding box corners + x_end = x_start + x_delta + y_end = y_start + y_delta + + # Original bounding box (before rotation) + original_box = { + "start": [x_start, y_start], + "end": [x_end, y_end] + } + + # Center of rotation (optical axis) + cx = self.optical_axis_x + cy = self.optical_axis_y + + scan_boxes = [] + + for angle_idx in range(num_angles): + # Calculate rotation angle in radians (CCW) + # Use 180° instead of 360° since data is symmetric + angle_deg = (180.0 / num_angles) * angle_idx + angle_rad = math.radians(angle_deg) + + # Rotate all four corners of the bounding box + corners = [ + (x_start, y_start), # Bottom-left + (x_end, y_start), # Bottom-right + (x_end, y_end), # Top-right + (x_start, y_end) # Top-left + ] + + rotated_corners = [] + for x, y in corners: + # Rotate around optical axis center (CCW) + x_rot = cx + (x - cx) * math.cos(angle_rad) - (y - cy) * math.sin(angle_rad) + y_rot = cy + (x - cx) * math.sin(angle_rad) + (y - cy) * math.cos(angle_rad) + rotated_corners.append((x_rot, y_rot)) + + # Find the new bounding box that encompasses all rotated corners + x_coords = [c[0] for c in rotated_corners] + y_coords = [c[1] for c in rotated_corners] + + rotated_x_start = min(x_coords) + rotated_x_end = max(x_coords) + rotated_y_start = min(y_coords) + rotated_y_end = max(y_coords) + + scan_box = { + "angle_index": angle_idx, + "angle_degrees": angle_deg, + "start": [round(rotated_x_start, 4), round(rotated_y_start, 4)], + "end": [round(rotated_x_end, 4), round(rotated_y_end, 4)], + "original_corners": [ + [round(c[0], 4), round(c[1], 4)] for c in rotated_corners + ] + } + + scan_boxes.append(scan_box) + + return scan_boxes + + def update_scan_metadata_finished(self): + """Update the scan metadata file to mark scan as finished""" + if not hasattr(self, 'current_scan_metadata_file') or not self.current_scan_metadata_file: + print("Warning: No metadata file to update") + return + + try: + # Read existing metadata + with open(self.current_scan_metadata_file, 'r') as f: + metadata = json.load(f) + + # Update scan_finished flag + metadata["scan_info"]["scan_finished"] = True + + # Add completion timestamp + from datetime import datetime + metadata["scan_info"]["completion_timestamp"] = datetime.now().isoformat() + + # Write back to file + with open(self.current_scan_metadata_file, 'w') as f: + json.dump(metadata, f, indent=2) + + print(f"Updated scan metadata: scan_finished = True") + + except Exception as e: + print(f"Error updating scan metadata: {e}") + import traceback + traceback.print_exc() + + +class ScanVisualizationDialog(QtWidgets.QDialog): + """Dialog showing scan visualization with rotation animation""" + + def __init__(self, parent=None, scan_params=None, motion_worker=None): + super().__init__(parent) + self.setWindowTitle("Scan Visualization") + self.setModal(False) + self.setMinimumWidth(800) + self.setMinimumHeight(700) + + # Store scan parameters + self.scan_params = scan_params or {} + self.motion_worker = motion_worker + self.current_angle_index = 0 + self.pixels_per_mm = 6.0 + self.optical_axis_x = 55.0 + self.optical_axis_y = 35.0 + + # Store scan visualization items for easy removal + self.scan_items = [] + + # Position indicator (black dot showing current stage position) + self.position_indicator = None + + # Scan worker and thread + self.scan_worker = None + self.scan_thread = None + + # Create layout + main_layout = QtWidgets.QVBoxLayout() + + # Info label + self.info_label = QtWidgets.QLabel("Scan Visualization - Click 'Next Angle' to rotate") + self.info_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + font = self.info_label.font() + font.setPointSize(12) + font.setBold(True) + self.info_label.setFont(font) + main_layout.addWidget(self.info_label) + + # Angle display + self.angle_label = QtWidgets.QLabel("Angle: 0° (Scan 1 of 1)") + self.angle_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + angle_font = self.angle_label.font() + angle_font.setPointSize(11) + self.angle_label.setFont(angle_font) + main_layout.addWidget(self.angle_label) + + # Graphics view + self.scene = QtWidgets.QGraphicsScene() + self.graphics_view = QtWidgets.QGraphicsView(self.scene) + self.graphics_view.setMinimumSize(600, 500) + main_layout.addWidget(self.graphics_view) + + # Progress bars + progress_layout = QtWidgets.QVBoxLayout() + + # Current scan progress + self.current_scan_label = QtWidgets.QLabel("Current Scan: Waiting to start...") + progress_layout.addWidget(self.current_scan_label) + + self.current_scan_progress = QtWidgets.QProgressBar() + self.current_scan_progress.setRange(0, 100) + self.current_scan_progress.setValue(0) + progress_layout.addWidget(self.current_scan_progress) + + # Overall progress + self.overall_progress_label = QtWidgets.QLabel("Overall Progress: 0 of 0 scans") + progress_layout.addWidget(self.overall_progress_label) + + self.overall_progress = QtWidgets.QProgressBar() + self.overall_progress.setRange(0, 100) + self.overall_progress.setValue(0) + progress_layout.addWidget(self.overall_progress) + + main_layout.addLayout(progress_layout) + + # Button layout + button_layout = QtWidgets.QHBoxLayout() + + self.btn_prev = QtWidgets.QPushButton("← Previous Angle") + self.btn_prev.clicked.connect(self.on_prev_angle) + self.btn_prev.setEnabled(False) + button_layout.addWidget(self.btn_prev) + + self.btn_next = QtWidgets.QPushButton("Next Angle →") + self.btn_next.clicked.connect(self.on_next_angle) + button_layout.addWidget(self.btn_next) + + button_layout.addStretch() + + self.btn_start_scan = QtWidgets.QPushButton("Start Scan") + self.btn_start_scan.clicked.connect(self.on_start_scan) + button_layout.addWidget(self.btn_start_scan) + + self.btn_close = QtWidgets.QPushButton("Close") + self.btn_close.clicked.connect(self.accept) + button_layout.addWidget(self.btn_close) + + main_layout.addLayout(button_layout) + + # Scanning state + self.is_scanning = False + self.scan_state = { + 'angle_idx': 0, + 'line_idx': 0, + 'scan_lines': [], + 'current_scan_box': None + } + self.setLayout(main_layout) + + # Initialize the visualization + self.setup_scene() + self.draw_current_angle() + + # Auto-start scanning after a short delay + QtCore.QTimer.singleShot(500, self.on_start_scan) + + def setup_scene(self): + """Set up the graphics scene with static elements""" + # Set scene size + diameter_mm = 50.0 + scene_size = diameter_mm * self.pixels_per_mm + self.scene.setSceneRect(-scene_size/2, -scene_size/2, scene_size, scene_size) + + # Draw the 50mm diameter circle (working area boundary) + pen = QtGui.QPen(QtGui.QColor(100, 100, 100)) + pen.setWidth(2) + radius = scene_size / 2 + self.scene.addEllipse(-radius, -radius, 2*radius, 2*radius, pen) + + # Draw crosshair at center (optical axis) + crosshair_pen = QtGui.QPen(QtGui.QColor(0, 0, 0)) + crosshair_pen.setWidth(1) + crosshair_size = 15 + self.scene.addLine(-crosshair_size, 0, crosshair_size, 0, crosshair_pen) + self.scene.addLine(0, -crosshair_size, 0, crosshair_size, crosshair_pen) + + # Position indicator (black dot, 3px diameter) + position_pen = QtGui.QPen(QtGui.QColor(0, 0, 0)) + position_brush = QtGui.QBrush(QtGui.QColor(0, 0, 0)) + self.position_indicator = self.scene.addEllipse(-1.5, -1.5, 3, 3, position_pen, position_brush) + self.position_indicator.setZValue(100) # Always on top + + # Connect to motion worker position updates + if self.motion_worker: + self.motion_worker.position_updated.connect(self.update_position_indicator) + + # Sample circle will be drawn in draw_current_angle (it rotates) + + def draw_current_angle(self): + """Draw the rotated sample and fixed-direction scan lines""" + # Remove previous scan visualization items + for item in self.scan_items: + self.scene.removeItem(item) + self.scan_items.clear() + + num_angles = self.scan_params.get('num_angles', 1) + if num_angles == 0: + return + + # Get scan boxes + scan_boxes = self.scan_params.get('scan_boxes', []) + if self.current_angle_index >= len(scan_boxes): + return + + scan_box = scan_boxes[self.current_angle_index] + angle_deg = scan_box['angle_degrees'] + + # Update label + self.angle_label.setText( + f"Angle: {angle_deg:.1f}° (Scan {self.current_angle_index + 1} of {num_angles})" + ) + + # Draw the ROTATED sample in red (original scan area rotates with sample) + self.draw_rotated_sample(angle_deg) + + # Draw the FIXED scan area (bounding box of rotated sample) + # Scan always happens in X+ direction, stepping in Y+ + self.draw_fixed_scan_area(scan_box) + + def draw_rotated_sample(self, angle_deg): + """Draw the original scan area rotated with the sample""" + import math + + # Get original scan parameters + x_start = self.scan_params.get('x_start', 0) + x_delta = self.scan_params.get('x_delta', 0) + y_start = self.scan_params.get('y_start', 0) + y_delta = self.scan_params.get('y_delta', 0) + + angle_rad = math.radians(angle_deg) + cx = self.optical_axis_x + cy = self.optical_axis_y + + # Define original scan area corners + x_end = x_start + x_delta + y_end = y_start + y_delta + corners = [ + (x_start, y_start), + (x_end, y_start), + (x_end, y_end), + (x_start, y_end) + ] + + # Rotate corners + polygon = QtGui.QPolygonF() + for x, y in corners: + x_rot = cx + (x - cx) * math.cos(angle_rad) - (y - cy) * math.sin(angle_rad) + y_rot = cy + (x - cx) * math.sin(angle_rad) + (y - cy) * math.cos(angle_rad) + + # Convert to scene coordinates (standard Cartesian: +X right, +Y up) + x_scene = (x_rot - self.optical_axis_x) * self.pixels_per_mm + y_scene = (y_rot - self.optical_axis_y) * self.pixels_per_mm + polygon.append(QtCore.QPointF(x_scene, -y_scene)) # Negate for Qt's Y-down + + # Draw rotated sample area in red + sample_pen = QtGui.QPen(QtGui.QColor(255, 0, 0)) # Red + sample_pen.setWidth(2) + sample_brush = QtGui.QBrush(QtGui.QColor(255, 0, 0, 30)) # Semi-transparent red + polygon_item = self.scene.addPolygon(polygon, sample_pen, sample_brush) + polygon_item.setZValue(5) + self.scan_items.append(polygon_item) + + def draw_fixed_scan_area(self, scan_box): + """Draw the bounding box and scan lines (always X+, stepping Y+)""" + # Get the bounding box (axis-aligned, non-rotated) + x_start_stage = scan_box['start'][0] + y_start_stage = scan_box['start'][1] + x_end_stage = scan_box['end'][0] + y_end_stage = scan_box['end'][1] + + print(f" Bounding box (stage): X=[{x_start_stage:.2f}, {x_end_stage:.2f}], Y=[{y_start_stage:.2f}, {y_end_stage:.2f}]") + + # Convert to scene coordinates (standard Cartesian: +X right, +Y up) + x_start_scene = (x_start_stage - self.optical_axis_x) * self.pixels_per_mm + y_start_scene = (y_start_stage - self.optical_axis_y) * self.pixels_per_mm + x_end_scene = (x_end_stage - self.optical_axis_x) * self.pixels_per_mm + y_end_scene = (y_end_stage - self.optical_axis_y) * self.pixels_per_mm + + # Find actual min/max + x_min_scene = min(x_start_scene, x_end_scene) + x_max_scene = max(x_start_scene, x_end_scene) + y_min_scene = min(y_start_scene, y_end_scene) + y_max_scene = max(y_start_scene, y_end_scene) + + width_scene = x_max_scene - x_min_scene + height_scene = y_max_scene - y_min_scene + + print(f" Bounding box (scene): X=[{x_min_scene:.1f}, {x_max_scene:.1f}], Y=[{y_min_scene:.1f}, {y_max_scene:.1f}]") + print(f" Width={width_scene:.1f}, Height={height_scene:.1f}") + + # Draw bounding box in brown (negate Y for Qt's coordinate system) + scan_area_pen = QtGui.QPen(QtGui.QColor(139, 69, 19)) # Brown + scan_area_pen.setWidth(3) + scan_area_brush = QtGui.QBrush(QtGui.QColor(139, 69, 19, 50)) + rect_item = self.scene.addRect(x_min_scene, -y_max_scene, width_scene, height_scene, + scan_area_pen, scan_area_brush) + rect_item.setZValue(10) + self.scan_items.append(rect_item) + + # Draw scan lines (X+ direction, stepping in Y+) + row_spacing = self.scan_params.get('row_spacing', 1.0) + row_spacing_scene = row_spacing * self.pixels_per_mm + + scan_line_pen = QtGui.QPen(QtGui.QColor(0, 100, 200)) # Blue + scan_line_pen.setWidth(1) + + # Generate horizontal lines stepping in Y + num_lines = int(height_scene / row_spacing_scene) + 1 + print(f" Drawing {num_lines} scan lines") + + for i in range(num_lines + 1): + y_current = y_min_scene + i * row_spacing_scene + if y_current > y_max_scene: + break + + # Negate Y for Qt's coordinate system + line_item = self.scene.addLine(x_min_scene, -y_current, x_max_scene, -y_current, scan_line_pen) + line_item.setZValue(11) + self.scan_items.append(line_item) + + + def on_next_angle(self): + """Show the next angle""" + num_angles = self.scan_params.get('num_angles', 1) + if self.current_angle_index < num_angles - 1: + self.current_angle_index += 1 + self.draw_current_angle() + self.btn_prev.setEnabled(True) + + if self.current_angle_index >= num_angles - 1: + self.btn_next.setEnabled(False) + + def on_prev_angle(self): + """Show the previous angle""" + if self.current_angle_index > 0: + self.current_angle_index -= 1 + self.draw_current_angle() + self.btn_next.setEnabled(True) + + if self.current_angle_index == 0: + self.btn_prev.setEnabled(False) + + def update_position_indicator(self, x: float, y: float): + """Update the position indicator dot to show current stage position""" + if not self.position_indicator: + return + + # Convert stage coordinates to scene coordinates + x_scene = (x - self.optical_axis_x) * self.pixels_per_mm + y_scene = (y - self.optical_axis_y) * self.pixels_per_mm + + # Position the indicator (center at position, negate Y for Qt coordinates) + self.position_indicator.setPos(x_scene, -y_scene) + + def on_start_scan(self): + """Start the scanning process""" + if self.is_scanning: + return + + self.is_scanning = True + + # Disable navigation buttons during scan + self.btn_prev.setEnabled(False) + self.btn_next.setEnabled(False) + self.btn_start_scan.setEnabled(False) + + # Update info label + self.info_label.setText("Scanning in Progress...") + + # Check motion controller and home first + QtCore.QTimer.singleShot(100, self.execute_scan) + + def execute_scan(self): + """Initialize and start the scanning process in a worker thread""" + scan_boxes = self.scan_params.get('scan_boxes', []) + num_angles = len(scan_boxes) + + print(f"Starting scan with {num_angles} angles") + + # Check motion controller connection and home status + if not self.check_motion_controller_ready(): + self.current_scan_label.setText("ERROR: Motion controller not ready") + self.info_label.setText("Scan Failed - Check motion controller") + self.btn_close.setEnabled(True) + self.is_scanning = False + return + + # Pre-flight checks passed - ready to scan + print("\n=== Pre-flight checks complete - starting scan ===\n") + self.current_scan_label.setText("Ready - Starting scan...") + self.info_label.setText("Scanning in Progress...") + + # Reset progress bars + self.current_scan_progress.setValue(0) + self.overall_progress.setValue(0) + + # Create scan worker and thread + self.scan_worker = ScanWorker(self.scan_params, self.motion_worker) + self.scan_thread = QtCore.QThread() + self.scan_worker.moveToThread(self.scan_thread) + + # Connect signals + self.scan_worker.scan_started.connect(self.on_scan_started) + self.scan_worker.scan_completed.connect(self.on_scan_completed) + self.scan_worker.scan_failed.connect(self.on_scan_failed) + self.scan_worker.angle_started.connect(self.on_angle_started) + self.scan_worker.line_started.connect(self.on_line_started) + self.scan_worker.current_progress.connect(self.current_scan_progress.setValue) + self.scan_worker.overall_progress.connect(self.overall_progress.setValue) + self.scan_worker.status_message.connect(self.current_scan_label.setText) + + # Connect thread lifecycle + self.scan_thread.started.connect(self.scan_worker.run_scan) + self.scan_thread.finished.connect(self.scan_thread.deleteLater) + + # Start the thread + self.scan_thread.start() + + def on_scan_started(self): + """Handle scan started signal""" + print("Scan started in worker thread") + + def on_scan_completed(self): + """Handle scan completed signal""" + self.overall_progress_label.setText(f"Overall Progress: Complete") + self.current_scan_label.setText("Scan Complete!") + self.info_label.setText("Scan Complete - Click Close to finish") + self.btn_close.setEnabled(True) + self.is_scanning = False + self.cleanup_scan_thread() + + def on_scan_failed(self, error_msg): + """Handle scan failed signal""" + self.current_scan_label.setText(f"Scan Failed: {error_msg}") + self.info_label.setText("Scan Failed") + self.btn_close.setEnabled(True) + self.is_scanning = False + self.cleanup_scan_thread() + + def on_angle_started(self, angle_idx, total_angles): + """Handle angle started signal""" + self.current_angle_index = angle_idx + self.draw_current_angle() + self.overall_progress_label.setText( + f"Overall Progress: Scan {angle_idx + 1} of {total_angles}" + ) + + def on_line_started(self, line_idx, total_lines, y_position): + """Handle line started signal""" + self.current_scan_label.setText( + f"Scanning Row {line_idx + 1} of {total_lines} at Y={y_position:.2f}mm" + ) + + def cleanup_scan_thread(self): + """Clean up the scan thread""" + if self.scan_thread and self.scan_thread.isRunning(): + self.scan_thread.quit() + self.scan_thread.wait() + self.scan_thread = None + self.scan_worker = None + + def check_motion_controller_ready(self) -> bool: + """ + Check if motion controller is connected and homed. + If not connected, connect. If not homed, home axes. + + Returns: + True if ready for scanning, False otherwise + """ + if not self.motion_worker: + print("ERROR: No motion worker available") + QtWidgets.QMessageBox.critical( + self, + "Motion Controller Error", + "Motion worker is not available. Cannot proceed with scan." + ) + return False + + # Check if connected + if not self.motion_worker.is_connected: + print("Motion controller not connected - attempting to connect...") + self.current_scan_label.setText("Connecting to motion controller...") + + # Try to connect + self.motion_worker.queue_connect() + + # Wait up to 10 seconds for connection + import time + for i in range(100): # 100 * 100ms = 10 seconds + QtWidgets.QApplication.processEvents() + if self.motion_worker.is_connected: + print("Motion controller connected successfully") + break + time.sleep(0.1) + + if not self.motion_worker.is_connected: + print("ERROR: Failed to connect to motion controller") + QtWidgets.QMessageBox.critical( + self, + "Connection Error", + "Failed to connect to motion controller.\n\n" + "Please check:\n" + "• Stage controller is powered on\n" + "• USB connection is secure\n" + "• No other software is using the controller" + ) + return False + + # Check home status + controller = self.motion_worker.controller + if not controller: + print("ERROR: Controller object not available") + return False + + print("Checking home status...") + self.current_scan_label.setText("Checking home status...") + + # Get home status for both axes + x_homed = controller.is_homed_x + y_homed = controller.is_homed_y + + print(f"Home status - X: {x_homed}, Y: {y_homed}") + + # Home axes if needed + if not x_homed or not y_homed: + print("Axes not homed - homing required") + + # Ask user for confirmation + reply = QtWidgets.QMessageBox.question( + self, + "Homing Required", + "⚠️ STAGE HOMING REQUIRED ⚠️\n\n" + "The stage axes must be homed before scanning.\n\n" + "IMPORTANT:\n" + "• Ensure the stage can move freely in all directions\n" + "• Remove any obstructions from the stage path\n" + "• The stage will move to its home position\n\n" + "Do you want to home the stage now?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No + ) + + if reply != QtWidgets.QMessageBox.StandardButton.Yes: + print("User cancelled homing - aborting scan") + return False + + # Home X axis if needed + if not x_homed: + print("Homing X-axis...") + self.current_scan_label.setText("Homing X-axis... (this may take up to 60 seconds)") + self.current_scan_progress.setValue(30) + QtWidgets.QApplication.processEvents() + + try: + success = controller.home_axis(controller.DEST_X_AXIS, timeout=60.0) + if success: + print("X-axis homed successfully") + self.current_scan_progress.setValue(50) + + # Small delay to let system stabilize + import time + time.sleep(0.5) + else: + print("ERROR: X-axis homing timeout") + QtWidgets.QMessageBox.critical( + self, + "Homing Error", + "X-axis homing timed out after 60 seconds.\n\n" + "Please check:\n" + "• Stage can move freely\n" + "• No obstructions\n" + "• Stage is connected properly" + ) + return False + except Exception as e: + print(f"ERROR: Failed to home X-axis: {e}") + QtWidgets.QMessageBox.critical( + self, + "Homing Error", + f"Failed to home X-axis:\n{e}" + ) + return False + + # Home Y axis if needed + if not y_homed: + print("Homing Y-axis...") + self.current_scan_label.setText("Homing Y-axis... (this may take up to 60 seconds)") + self.current_scan_progress.setValue(60) + QtWidgets.QApplication.processEvents() + + try: + success = controller.home_axis(controller.DEST_Y_AXIS, timeout=60.0) + if success: + print("Y-axis homed successfully") + self.current_scan_progress.setValue(90) + + # Small delay to let system stabilize + import time + time.sleep(0.5) + else: + print("ERROR: Y-axis homing timeout") + QtWidgets.QMessageBox.critical( + self, + "Homing Error", + "Y-axis homing timed out after 60 seconds.\n\n" + "Please check:\n" + "• Stage can move freely\n" + "• No obstructions\n" + "• Stage is connected properly" + ) + return False + except Exception as e: + print(f"ERROR: Failed to home Y-axis: {e}") + QtWidgets.QMessageBox.critical( + self, + "Homing Error", + f"Failed to home Y-axis:\n{e}" + ) + return False + + print("All axes homed successfully") + self.current_scan_label.setText("Homing complete - ready to scan") + self.current_scan_progress.setValue(100) + QtWidgets.QApplication.processEvents() + + # Brief pause to show completion + import time + time.sleep(0.5) + + print("Motion controller ready for scanning") + return True + + def closeEvent(self, event): + """Handle dialog close event - clean up scan thread""" + # Stop scan if running + if self.scan_worker: + self.scan_worker.stop() + + # Clean up scan thread + if self.scan_thread and self.scan_thread.isRunning(): + print("Stopping scan thread...") + self.scan_thread.quit() + if not self.scan_thread.wait(5000): # Wait up to 5 seconds (position requests can take 1.5s each) + print("Warning: Scan thread did not stop gracefully") + + self.scan_thread = None + self.scan_worker = None + + super().closeEvent(event) + +class ScanProgressDialog(QtWidgets.QDialog): + """Dialog showing scan progress with two progress bars""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Scan in Progress") + self.setModal(True) + self.setMinimumWidth(500) + self.setMinimumHeight(200) + + # Create layout + layout = QtWidgets.QVBoxLayout() + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + # Current scan section + self.label_current_scan = QtWidgets.QLabel("Scanning Row 0 of 0") + self.label_current_scan.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + font_current = self.label_current_scan.font() + font_current.setPointSize(12) + self.label_current_scan.setFont(font_current) + layout.addWidget(self.label_current_scan) + + self.progress_current_scan = QtWidgets.QProgressBar() + self.progress_current_scan.setMinimum(0) + self.progress_current_scan.setMaximum(100) + self.progress_current_scan.setValue(0) + self.progress_current_scan.setTextVisible(True) + self.progress_current_scan.setFormat("%p%") + self.progress_current_scan.setMinimumHeight(30) + layout.addWidget(self.progress_current_scan) + + # Add spacer + layout.addSpacing(20) + + # Total progress section + self.label_total_progress = QtWidgets.QLabel("Scan 0 of 0") + self.label_total_progress.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + font_total = self.label_total_progress.font() + font_total.setPointSize(12) + self.label_total_progress.setFont(font_total) + layout.addWidget(self.label_total_progress) + + self.progress_total = QtWidgets.QProgressBar() + self.progress_total.setMinimum(0) + self.progress_total.setMaximum(100) + self.progress_total.setValue(0) + self.progress_total.setTextVisible(True) + self.progress_total.setFormat("%p%") + self.progress_total.setMinimumHeight(30) + layout.addWidget(self.progress_total) + + # Add spacer before buttons + layout.addStretch() + + # Cancel button + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch() + + self.btn_cancel = QtWidgets.QPushButton("Cancel Scan") + self.btn_cancel.setMinimumWidth(120) + self.btn_cancel.clicked.connect(self.on_cancel_clicked) + button_layout.addWidget(self.btn_cancel) + + button_layout.addStretch() + layout.addLayout(button_layout) + + self.setLayout(layout) + + # Scan state + self.scan_cancelled = False + + def update_current_scan(self, current_row: int, total_rows: int): + """ + Update the current scan progress bar. + + Args: + current_row: Current row being scanned (1-indexed) + total_rows: Total number of rows in this scan + """ + self.label_current_scan.setText(f"Scanning Row {current_row} of {total_rows}") + + if total_rows > 0: + percentage = int((current_row / total_rows) * 100) + self.progress_current_scan.setValue(percentage) + else: + self.progress_current_scan.setValue(0) + + def update_total_progress(self, current_scan: int, total_scans: int): + """ + Update the total progress bar. + + Args: + current_scan: Current scan number (1-indexed) + total_scans: Total number of scans + """ + self.label_total_progress.setText(f"Scan {current_scan} of {total_scans}") + + if total_scans > 0: + percentage = int((current_scan / total_scans) * 100) + self.progress_total.setValue(percentage) + else: + self.progress_total.setValue(0) + + def reset_current_scan(self): + """Reset the current scan progress bar to 0""" + self.progress_current_scan.setValue(0) + + def on_cancel_clicked(self): + """Handle cancel button click""" + reply = QtWidgets.QMessageBox.question( + self, + "Cancel Scan", + "Are you sure you want to cancel the scan in progress?\n\n" + "The current scan will be stopped and data may be incomplete.\n" + "You will be returned to the main menu.", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + self.scan_cancelled = True + self.btn_cancel.setEnabled(False) + self.btn_cancel.setText("Cancelling...") + print("User requested scan cancellation") + + def is_cancelled(self) -> bool: + """Check if user has requested to cancel the scan""" + return self.scan_cancelled + + def scan_complete(self): + """Call this when the scan is complete""" + self.label_current_scan.setText("Scan Complete!") + self.progress_current_scan.setValue(100) + self.progress_total.setValue(100) + self.btn_cancel.setText("Close") + self.btn_cancel.clicked.disconnect() + self.btn_cancel.clicked.connect(self.accept) + self.btn_cancel.setEnabled(True) + + +class OptionsDialog(QtWidgets.QDialog): + """Options/Configuration dialog""" + + CONFIG_FILE = "config.json" + + def __init__(self, parent=None): + super().__init__(parent) + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), 'options.ui') + uic.loadUi(ui_path, self) + + # Populate trigger mode combo boxes + self.populate_trigger_modes() + + # Connect signals to slots + self.setup_connections() + + # Load configuration and populate UI + self.load_configuration() + + def populate_trigger_modes(self): + """Populate the trigger mode combo boxes with available options""" + from hardware.bbd202 import TriggerMode + + trigger_options = [ + ("Disabled", TriggerMode.DISABLED), + ("In/Out Relative Move", TriggerMode.IN_OUT_RELATIVE_MOVE), + ("In/Out Absolute Move", TriggerMode.IN_OUT_ABSOLUTE_MOVE), + ("In/Out Home", TriggerMode.IN_OUT_HOME), + ("In/Out Stop", TriggerMode.IN_OUT_STOP), + ("Out Only (HIGH during motion)", TriggerMode.OUT_ONLY), + ("Out Position", TriggerMode.OUT_POSITION), + ] + + for label, mode in trigger_options: + self.combo_x_trigmode.addItem(label, mode) + self.combo_y_trigmode.addItem(label, mode) + + def setup_connections(self): + """Connect UI controls to their event handlers""" + # Dialog buttons + self.pb_updateconfig.clicked.connect(self.on_update_config_clicked) + self.pb_cancelconfig.clicked.connect(self.on_cancel_config_clicked) + + # Detection Laser tab + self.le_detection_scanpower.textChanged.connect(self.on_detection_scanpower_changed) + self.pb_test_genesis_connection.clicked.connect(self.on_test_genesis_connection_clicked) + + # Generation Laser tab + self.le_generation_comport.textChanged.connect(self.on_generation_comport_changed) + self.pb_autodetect_genlaser.clicked.connect(self.on_autodetect_genlaser_clicked) + self.le_generation_frequency.textChanged.connect(self.on_generation_frequency_changed) + self.le_pumpdiode_current.textChanged.connect(self.on_pumpdiode_current_changed) + self.le_generation_reset.clicked.connect(self.on_generation_reset_clicked) + + # Scanning Stage tab + self.le_scan_velocity.textChanged.connect(self.on_scan_velocity_changed) + self.le_scan_accel.textChanged.connect(self.on_scan_accel_changed) + self.combo_x_trigmode.currentIndexChanged.connect(self.on_x_trigmode_changed) + self.combo_y_trigmode.currentIndexChanged.connect(self.on_y_trigmode_changed) + self.le_optical_xcoord.textChanged.connect(self.on_optical_xcoord_changed) + self.le_optical_ycoord.textChanged.connect(self.on_optical_ycoord_changed) + + # T3R tab + self.le_t3r_comport.textChanged.connect(self.on_t3r_comport_changed) + self.pb_t3r_autodetect.clicked.connect(self.on_t3r_autodetect_clicked) + self.pb_t3r_test_connection.clicked.connect(self.on_t3r_test_connection_clicked) + + # Oscilloscope tab + self.le_oscope_socket_addr.textChanged.connect(self.on_oscope_socket_addr_changed) + self.le_data_scratchdir.textChanged.connect(self.on_data_scratchdir_changed) + self.pushButton_5.clicked.connect(self.on_browse_scratchdir_clicked) + self.rdo_savetopc.toggled.connect(self.on_savetopc_toggled) + self.rdo_savetoscope.toggled.connect(self.on_savetoscope_toggled) + self.pb_test_scope.clicked.connect(self.on_test_scope_clicked) + + # ===== Configuration Management ===== + def load_configuration(self): + """Load configuration from JSON file and populate UI controls""" + try: + with open(self.CONFIG_FILE, 'r') as f: + config = json.load(f) + + # Detection Laser + self.le_detection_scanpower.setText(config['detection_laser']['scan_power_mw']) + + # Generation Laser + self.le_generation_comport.setText(config['generation_laser']['com_port']) + self.le_generation_frequency.setText(config['generation_laser']['frequency_hz']) + self.le_pumpdiode_current.setText(config['generation_laser']['pump_diode_current_ma']) + + # Generation Laser - Focusing parameters (with defaults if not present) + focusing_freq = config['generation_laser'].get('focusing_frequency_hz', '20000') + focusing_current = config['generation_laser'].get('focusing_pump_current_ma', '300') + self.le_generation_focusing_frequency.setText(str(focusing_freq)) + self.le_pumpdiode_focusing_current.setText(str(focusing_current)) + + # Scanning Stage + self.le_scan_velocity.setText(config['scanning_stage']['scan_velocity_mm_s']) + self.le_scan_accel.setText(config['scanning_stage']['scan_acceleration_mm_s2']) + self.combo_x_trigmode.setCurrentIndex(config['scanning_stage']['x_trigger_mode']) + self.combo_y_trigmode.setCurrentIndex(config['scanning_stage']['y_trigger_mode']) + self.le_optical_xcoord.setText(config['scanning_stage']['optical_axis_x_mm']) + self.le_optical_ycoord.setText(config['scanning_stage']['optical_axis_y_mm']) + + # T3R + self.le_t3r_comport.setText(config['t3r']['com_port']) + + # Oscilloscope + self.le_oscope_socket_addr.setText(config['oscilloscope']['socket_address']) + self.le_data_scratchdir.setText(config['oscilloscope']['scratch_directory']) + + # Set radio button based on save location + if config['oscilloscope']['save_location'] == 'pc': + self.rdo_savetopc.setChecked(True) + else: + self.rdo_savetoscope.setChecked(True) + + print("Configuration loaded successfully") + + except FileNotFoundError: + print(f"Configuration file '{self.CONFIG_FILE}' not found. Using defaults.") + except (json.JSONDecodeError, KeyError) as e: + print(f"Error loading configuration: {e}") + QtWidgets.QMessageBox.warning( + self, "Configuration Error", + f"Error loading configuration file: {e}\nUsing default values." + ) + + def validate_configuration(self): + """Validate all configuration values before saving""" + errors = [] + + # Validate Detection Laser - Scanning Power (0-500 mW) + try: + scan_power = float(self.le_detection_scanpower.text()) + if not (0 <= scan_power <= 500): + errors.append("Scanning Power must be between 0 and 500 mW") + except ValueError: + errors.append("Scanning Power must be a valid number") + + # Validate Generation Laser - Scanning Frequency (20kHz - 100kHz = 20000-100000 Hz) + try: + frequency = float(self.le_generation_frequency.text()) + if not (20000 <= frequency <= 100000): + errors.append("Scanning Frequency must be between 20,000 and 100,000 Hz (20-100 kHz)") + except ValueError: + errors.append("Scanning Frequency must be a valid number") + + # Validate Generation Laser - Scanning Pump Diode Current (250-1500 mA) + try: + current = float(self.le_pumpdiode_current.text()) + if not (250 <= current <= 1500): + errors.append("Scanning Pump Diode Current must be between 250 and 1500 mA") + except ValueError: + errors.append("Scanning Pump Diode Current must be a valid number") + + # Validate Generation Laser - Focusing Frequency (16.7kHz - 125kHz = 16700-125000 Hz) + try: + focusing_freq = float(self.le_generation_focusing_frequency.text()) + if not (16700 <= focusing_freq <= 125000): + errors.append("Focusing Frequency must be between 16,700 and 125,000 Hz (16.7-125 kHz)") + except ValueError: + errors.append("Focusing Frequency must be a valid number") + + # Validate Generation Laser - Focusing Pump Diode Current (0-7000 mA, recommend 250-500 for focusing) + try: + focusing_current = float(self.le_pumpdiode_focusing_current.text()) + if not (0 <= focusing_current <= 7000): + errors.append("Focusing Pump Diode Current must be between 0 and 7000 mA") + except ValueError: + errors.append("Focusing Pump Diode Current must be a valid number") + + # Validate Scanning Stage - Velocity (max 200 mm/s) + try: + velocity = float(self.le_scan_velocity.text()) + if velocity > 200 or velocity < 0: + errors.append("Scan Velocity must be between 0 and 200 mm/s") + except ValueError: + errors.append("Scan Velocity must be a valid number") + + # Validate Scanning Stage - Acceleration (max 2000 mm/s^2 = 2 m/s^2) + try: + acceleration = float(self.le_scan_accel.text()) + if acceleration > 2000 or acceleration < 0: + errors.append("Scan Acceleration must be between 0 and 2000 mm/s² (2 m/s²)") + except ValueError: + errors.append("Scan Acceleration must be a valid number") + + # Validate Oscilloscope - Socket Address (IPv4) + try: + ipaddress.IPv4Address(self.le_oscope_socket_addr.text()) + except ValueError: + errors.append("Oscilloscope Socket Address must be a valid IPv4 address (e.g., 192.168.1.100)") + + # Display errors if any + if errors: + error_message = "Configuration validation failed:\n\n" + "\n".join(f"• {error}" for error in errors) + QtWidgets.QMessageBox.warning( + self, "Validation Error", error_message + ) + return False + + return True + + def save_configuration(self): + """Save current UI values to JSON configuration file""" + config = { + "detection_laser": { + "scan_power_mw": self.le_detection_scanpower.text() + }, + "generation_laser": { + "com_port": self.le_generation_comport.text(), + "frequency_hz": self.le_generation_frequency.text(), + "pump_diode_current_ma": self.le_pumpdiode_current.text(), + "focusing_frequency_hz": self.le_generation_focusing_frequency.text(), + "focusing_pump_current_ma": self.le_pumpdiode_focusing_current.text() + }, + "scanning_stage": { + "scan_velocity_mm_s": self.le_scan_velocity.text(), + "scan_acceleration_mm_s2": self.le_scan_accel.text(), + "x_trigger_mode": self.combo_x_trigmode.currentIndex(), + "y_trigger_mode": self.combo_y_trigmode.currentIndex(), + "optical_axis_x_mm": self.le_optical_xcoord.text(), + "optical_axis_y_mm": self.le_optical_ycoord.text() + }, + "t3r": { + "com_port": self.le_t3r_comport.text() + }, + "oscilloscope": { + "socket_address": self.le_oscope_socket_addr.text(), + "scratch_directory": self.le_data_scratchdir.text(), + "save_location": "pc" if self.rdo_savetopc.isChecked() else "scope" + } + } + + try: + with open(self.CONFIG_FILE, 'w') as f: + json.dump(config, indent=2, fp=f) + print("Configuration saved successfully") + return True + except Exception as e: + print(f"Error saving configuration: {e}") + QtWidgets.QMessageBox.critical( + self, "Save Error", + f"Failed to save configuration: {e}" + ) + return False + + # ===== Dialog Button Handlers ===== + def on_update_config_clicked(self): + """Handle Update Configuration button click""" + print("Updating configuration...") + # Validate first, then save + if self.validate_configuration(): + if self.save_configuration(): + self.accept() + + def on_cancel_config_clicked(self): + """Handle Cancel button click""" + print("Configuration cancelled") + self.reject() + + # ===== Detection Laser Tab Handlers ===== + def on_detection_scanpower_changed(self, text): + """Handle detection scan power text change""" + print(f"Detection scan power changed: {text}") + + def on_test_genesis_connection_clicked(self): + """Handle test genesis connection button click""" + print("Testing genesis connection...") + + # Update status label to show we're connecting + self.label_4.setText("Connecting...") + QtWidgets.QApplication.processEvents() # Force UI update + + try: + # Use DummyLaser for now until I2C protocol is fully debugged + from coherent_hops_laser import DummyLaser + + laser = DummyLaser() + laser.connect() + print("Connected to Genesis laser (using simulator)") + + # Query laser information + serial_number = laser.get_hardware_id() + model = laser.get_laser_model() + interlock_state = laser.get_interlock_status() + keyswitch_state = laser.get_key_switch_status() + main_temp = laser.get_temperature_main() + eta_temp = laser.get_temperature_eta() + + # Update UI labels with the retrieved information + self.l_detection_serialnum.setText(serial_number) + self.l_detection_modelname.setText(model) + self.l_detection_interlock.setText(interlock_state) + self.l_detection_keyswitch.setText(keyswitch_state) + self.l_detection_heatsink_temp.setText(f"{main_temp:.1f}°C") + self.l_detection_eta_temp.setText(f"{eta_temp:.1f}°C") + + # Update status label to show success + self.label_4.setText("Connected ✓ (Simulator)") + + # Disconnect from the laser + laser.disconnect() + + print("Genesis laser query completed successfully (simulator mode)") + + except Exception as e: + # Update status label to show error + self.label_4.setText("Error") + + # Show error message to user + error_msg = f"Failed to connect to Genesis laser:\n{str(e)}" + print(error_msg) + QtWidgets.QMessageBox.critical( + self, "Connection Error", error_msg + ) + + # ===== Generation Laser Tab Handlers ===== + def on_generation_comport_changed(self, text): + """Handle generation laser COM port text change""" + print(f"Generation laser COM port changed: {text}") + + def on_autodetect_genlaser_clicked(self): + """Handle autodetect generation laser button click""" + print("Autodetecting generation laser...") + # TODO: Implement autodetection + + def on_generation_frequency_changed(self, text): + """Handle generation laser frequency text change""" + print(f"Generation laser frequency changed: {text}") + + def on_pumpdiode_current_changed(self, text): + """Handle pump diode current text change""" + print(f"Pump diode current changed: {text}") + + def on_generation_reset_clicked(self): + """Handle generation laser reset button click""" + print("Resetting generation laser...") + # TODO: Implement laser reset + + # ===== Scanning Stage Tab Handlers ===== + def on_scan_velocity_changed(self, text): + """Handle scan velocity text change""" + print(f"Scan velocity changed: {text}") + + def on_scan_accel_changed(self, text): + """Handle scan acceleration text change""" + print(f"Scan acceleration changed: {text}") + + def on_x_trigmode_changed(self, index): + """Handle X axis trigger mode change""" + print(f"X axis trigger mode changed to index: {index}") + + def on_y_trigmode_changed(self, index): + """Handle Y axis trigger mode change""" + print(f"Y axis trigger mode changed to index: {index}") + + def on_optical_xcoord_changed(self, text): + """Handle optical axis X coordinate text change""" + print(f"Optical axis X coordinate changed: {text}") + + def on_optical_ycoord_changed(self, text): + """Handle optical axis Y coordinate text change""" + print(f"Optical axis Y coordinate changed: {text}") + + # ===== T3R Tab Handlers ===== + def on_t3r_comport_changed(self, text): + """Handle T3R COM port text change""" + print(f"T3R COM port changed: {text}") + + def on_t3r_autodetect_clicked(self): + """Handle T3R autodetect button click""" + print("Autodetecting T3R device...") + # TODO: Implement autodetection + + def on_t3r_test_connection_clicked(self): + """Handle T3R test connection button click""" + print("Testing T3R connection...") + # TODO: Implement connection test + + # ===== Oscilloscope Tab Handlers ===== + def on_oscope_socket_addr_changed(self, text): + """Handle oscilloscope socket address text change""" + print(f"Oscilloscope socket address changed: {text}") + + def on_data_scratchdir_changed(self, text): + """Handle data scratch directory text change""" + print(f"Data scratch directory changed: {text}") + + def on_browse_scratchdir_clicked(self): + """Handle browse scratch directory button click""" + print("Browse for scratch directory") + directory = QtWidgets.QFileDialog.getExistingDirectory( + self, "Select Scratch Directory", "" + ) + if directory: + self.le_data_scratchdir.setText(directory) + + def on_savetopc_toggled(self, checked): + """Handle save to PC radio button toggle""" + print(f"Save to PC toggled: {checked}") + + def on_savetoscope_toggled(self, checked): + """Handle save to oscilloscope radio button toggle""" + print(f"Save to oscilloscope toggled: {checked}") + + def on_test_scope_clicked(self): + """Handle test oscilloscope connection button click""" + print("Testing oscilloscope connection...") + # TODO: Implement connection test + + +class JogStageDialog(QtWidgets.QDialog): + """Dialog for jogging the stage to position the sample""" + + def __init__(self, parent=None, shared_motion_worker=None): + super().__init__(parent) + + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), 'jog_stage_dialog.ui') + uic.loadUi(ui_path, self) + + # Track if we're using a shared motion worker (don't disconnect on close) + self.shared_worker = shared_motion_worker is not None + + if shared_motion_worker: + # Use the shared motion worker from parent + self.motion_worker = shared_motion_worker + self.motion_thread = None # We don't own the thread + else: + # Create our own motion worker thread + self.motion_thread = QtCore.QThread() + self.motion_worker = MotionWorker() + self.motion_worker.moveToThread(self.motion_thread) + + # Default jog parameters + self.jog_speed = 20.0 # mm/s + self.step_size = 1.0 # mm + self.acceleration = 50.0 # mm/s^2 + + # Current positions + self.x_position = 0.0 + self.y_position = 0.0 + + # Connection state + self.is_connected = False + + # Continuous jogging support + self.jog_timer = QtCore.QTimer(self) + self.jog_timer.timeout.connect(self.on_jog_timer) + self.jog_timer_interval = 100 # ms between jog steps when holding + self.current_jog_axis = None + self.current_jog_direction = None + + # Home warning tracking + self.home_warning_shown = False + + # Connect worker signals + self.setup_worker_signals() + + # Connect UI signals + self.setup_connections() + + if self.shared_worker: + # Already connected via shared worker - check current state + if self.motion_worker.is_connected: + self.on_worker_connected() + else: + self.set_jog_buttons_enabled(False) + self.label_status.setText("Status: Not Connected") + else: + # Disable jog buttons initially + self.set_jog_buttons_enabled(False) + + # Start the motion thread + self.motion_thread.started.connect(self.motion_worker.run) + self.motion_thread.start() + + # Auto-connect to stage controller + QtCore.QTimer.singleShot(100, self.auto_connect_stage) + + def setup_worker_signals(self): + """Connect signals from motion worker to UI handlers""" + # Connection signals + self.motion_worker.connected.connect(self.on_worker_connected) + self.motion_worker.disconnected.connect(self.on_worker_disconnected) + self.motion_worker.connection_failed.connect(self.on_worker_connection_failed) + + # Position and status signals + self.motion_worker.position_updated.connect(self.on_worker_position_updated) + self.motion_worker.homed_status.connect(self.on_worker_homed_status) + self.motion_worker.move_completed.connect(self.on_worker_move_completed) + + # Error signals + self.motion_worker.error_occurred.connect(self.on_worker_error) + + def setup_connections(self): + """Connect UI controls to their event handlers""" + # Connection and control buttons + self.btn_connect.clicked.connect(self.on_connect_clicked) + self.btn_home.clicked.connect(self.on_home_clicked) + self.btn_close.clicked.connect(self.close) + + # Jog buttons - use pressed/released for continuous jogging + self.btn_jog_x_plus.pressed.connect(lambda: self.start_jogging('x', +1)) + self.btn_jog_x_plus.released.connect(self.stop_jogging) + + self.btn_jog_x_minus.pressed.connect(lambda: self.start_jogging('x', -1)) + self.btn_jog_x_minus.released.connect(self.stop_jogging) + + self.btn_jog_y_plus.pressed.connect(lambda: self.start_jogging('y', +1)) + self.btn_jog_y_plus.released.connect(self.stop_jogging) + + self.btn_jog_y_minus.pressed.connect(lambda: self.start_jogging('y', -1)) + self.btn_jog_y_minus.released.connect(self.stop_jogging) + + # Speed and step size changes + self.le_jog_speed.textChanged.connect(self.on_jog_speed_changed) + self.le_step_size.textChanged.connect(self.on_step_size_changed) + + def set_jog_buttons_enabled(self, enabled: bool): + """Enable or disable jog buttons""" + self.btn_jog_x_plus.setEnabled(enabled) + self.btn_jog_x_minus.setEnabled(enabled) + self.btn_jog_y_plus.setEnabled(enabled) + self.btn_jog_y_minus.setEnabled(enabled) + self.btn_home.setEnabled(enabled) + + def auto_connect_stage(self): + """Automatically connect to the stage controller on dialog open""" + self.label_status.setText("Status: Auto-connecting...") + # Queue connect command to worker + self.motion_worker.queue_connect() + + # Worker signal handlers + def on_worker_connected(self): + """Handle successful connection from worker""" + print("Motion worker connected successfully") + self.is_connected = True + self.btn_connect.setText("Disconnect") + self.label_status.setText("Status: Connected") + self.set_jog_buttons_enabled(True) + + # Set velocity parameters + self.motion_worker.queue_set_velocity(self.jog_speed, self.acceleration) + + def on_worker_disconnected(self): + """Handle disconnection from worker""" + print("Motion worker disconnected") + self.is_connected = False + self.btn_connect.setText("Connect") + self.label_status.setText("Status: Disconnected") + self.set_jog_buttons_enabled(False) + + def on_worker_connection_failed(self, error_msg: str): + """Handle connection failure from worker""" + print(f"Motion worker connection failed: {error_msg}") + self.label_status.setText("Status: Not Connected") + QtWidgets.QMessageBox.warning( + self, + "Connection Info", + f"Could not auto-connect to stage controller:\n{error_msg}\n\n" + "You can manually connect using the Connect button." + ) + + def on_worker_position_updated(self, x: float, y: float): + """Handle position update from worker""" + self.x_position = x + self.y_position = y + self.label_position.setText( + f"Position: X={self.x_position:.2f}mm, Y={self.y_position:.2f}mm" + ) + + def on_worker_homed_status(self, x_homed: bool, y_homed: bool): + """Handle homed status update from worker""" + print(f"Home status: X={'homed' if x_homed else 'not homed'}, Y={'homed' if y_homed else 'not homed'}") + + # If stage is now homed, reset the warning flag and update status + if x_homed and y_homed: + self.home_warning_shown = False + # Clear homing status if both axes are homed + if self.label_status.text() == "Status: Homing...": + self.label_status.setText("Status: Connected") + + # If not homed and we haven't shown the warning yet, show it + if (not x_homed or not y_homed) and not self.home_warning_shown: + self.home_warning_shown = True + self.show_home_warning(x_homed, y_homed) + + def on_worker_move_completed(self, axis: str): + """Handle move completion from worker""" + print(f"Move completed on {axis.upper()} axis") + + def on_worker_error(self, error_msg: str): + """Handle error from worker""" + print(f"Motion worker error: {error_msg}") + self.label_status.setText("Status: Error") + # Don't show message box for every error to avoid spam + + def show_home_warning(self, x_homed: bool, y_homed: bool): + """Show warning dialog when stage is not homed""" + status_msg = [] + if not x_homed: + status_msg.append("X axis is NOT homed") + if not y_homed: + status_msg.append("Y axis is NOT homed") + + reply = QtWidgets.QMessageBox.warning( + self, + "Stage Not Homed", + f"⚠️ STAGE HOMING REQUIRED ⚠️\n\n" + f"{', '.join(status_msg)}\n\n" + f"Current Position:\n" + f" X = {self.x_position:.2f} mm\n" + f" Y = {self.y_position:.2f} mm\n\n" + f"Before homing, please check for clearance:\n" + f"• Ensure the stage can move freely in all directions\n" + f"• Remove any obstructions from the stage path\n" + f"• Verify no samples or fixtures will be damaged\n\n" + f"Do you want to home the stage now?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + self.on_home_clicked() + + def on_connect_clicked(self): + """Handle connect button click""" + if not self.is_connected: + self.label_status.setText("Status: Connecting...") + self.motion_worker.queue_connect() + else: + # Disconnect + self.disconnect_controller() + + def disconnect_controller(self): + """Disconnect from the stage controller""" + self.motion_worker.queue_disconnect() + + def on_home_clicked(self): + """Handle home all axes button click""" + if not self.is_connected: + return + + self.label_status.setText("Status: Homing...") + + # Queue home commands for both axes + self.motion_worker.queue_home('x') + self.motion_worker.queue_home('y') + + def start_jogging(self, axis: str, direction: int): + """ + Start continuous jogging when button is pressed. + + Args: + axis: 'x' or 'y' + direction: +1 for positive direction, -1 for negative direction + """ + if not self.is_connected: + return + + # Store the jog parameters + self.current_jog_axis = axis + self.current_jog_direction = direction + + # Queue first jog immediately + self.motion_worker.queue_jog(axis, direction) + + # Start timer for continuous jogging + self.jog_timer.start(self.jog_timer_interval) + + def stop_jogging(self): + """Stop continuous jogging when button is released""" + # Stop the timer + self.jog_timer.stop() + + # Clear jog parameters + self.current_jog_axis = None + self.current_jog_direction = None + + # Restore normal status + if self.is_connected: + self.label_status.setText("Status: Connected") + + def on_jog_timer(self): + """Timer callback for continuous jogging""" + if self.current_jog_axis and self.current_jog_direction and self.is_connected: + # Queue jog command to worker + self.motion_worker.queue_jog(self.current_jog_axis, self.current_jog_direction) + + def on_jog_speed_changed(self, text): + """Handle jog speed text change""" + try: + speed = float(text) + if speed > 0 and speed <= 200: + self.jog_speed = speed + print(f"Jog speed changed to {speed} mm/s") + + # Update velocity parameters via worker + self.motion_worker.queue_set_velocity(self.jog_speed, self.acceleration) + except ValueError: + pass # Invalid input, ignore + + def on_step_size_changed(self, text): + """Handle step size text change""" + try: + step = float(text) + if step > 0: + self.step_size = step + print(f"Step size changed to {step} mm") + + # Update step size in worker + self.motion_worker.queue_set_step_size(step) + except ValueError: + pass # Invalid input, ignore + + def closeEvent(self, event): + """Handle dialog close event""" + # Stop any ongoing jogging + self.stop_jogging() + + # Disconnect signal handlers to avoid receiving updates after close + try: + self.motion_worker.connected.disconnect(self.on_worker_connected) + self.motion_worker.disconnected.disconnect(self.on_worker_disconnected) + self.motion_worker.connection_failed.disconnect(self.on_worker_connection_failed) + self.motion_worker.position_updated.disconnect(self.on_worker_position_updated) + self.motion_worker.homed_status.disconnect(self.on_worker_homed_status) + self.motion_worker.move_completed.disconnect(self.on_worker_move_completed) + self.motion_worker.error_occurred.disconnect(self.on_worker_error) + except (TypeError, RuntimeError): + pass # Signals may not be connected + + # Only disconnect and stop if we own the worker (not shared) + if not self.shared_worker: + # Disconnect from controller + self.disconnect_controller() + + # Stop the motion worker thread + self.motion_worker.stop() + if self.motion_thread: + self.motion_thread.quit() + self.motion_thread.wait(5000) # Wait up to 5 seconds (position requests can take 1.5s each) + + super().closeEvent(event) + + +def main(): + """Main application entry point""" + app = QtWidgets.QApplication(sys.argv) + + # Create and show the main launcher window + launcher = MainLauncher() + launcher.show() + + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/scanengine/jog_stage_dialog.ui b/scanengine/jog_stage_dialog.ui new file mode 100644 index 0000000..91bffa9 --- /dev/null +++ b/scanengine/jog_stage_dialog.ui @@ -0,0 +1,270 @@ + + + JogStageDialog + + + + 0 + 0 + 400 + 400 + + + + Jog Stage + + + + + + + 16 + 75 + true + + + + Stage Jogging Control + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 12 + + + + Position: X=0.00mm, Y=0.00mm + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Qt::Orientation::Vertical + + + + 20 + 20 + + + + + + + + + + + 80 + 60 + + + + + 14 + 75 + true + + + + +Y + + + + + + + + 80 + 60 + + + + + 14 + 75 + true + + + + +X + + + + + + + + + Jog Speed (mm/s): + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 100 + 16777215 + + + + Qt::AlignmentFlag::AlignCenter + + + 20.0 + + + + + + + Step Size (mm): + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 100 + 16777215 + + + + Qt::AlignmentFlag::AlignCenter + + + 1.0 + + + + + + + + + + 80 + 60 + + + + + 14 + 75 + true + + + + -X + + + + + + + + 80 + 60 + + + + + 14 + 75 + true + + + + -Y + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 20 + + + + + + + + Status: Disconnected + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + Connect + + + + + + + Home All Axes + + + false + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + diff --git a/scanengine/main_launcher.ui b/scanengine/main_launcher.ui new file mode 100644 index 0000000..3dfa39f --- /dev/null +++ b/scanengine/main_launcher.ui @@ -0,0 +1,109 @@ + + + MainWindow + + + + 0 + 0 + 653 + 360 + + + + + Bahnschrift + + + + Scanengin3 | v3.1.0 | build: nottagain + + + + + + + + + + Bahnschrift + 26 + + + + Scanengine Task Launcher: + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + Bahnschrift + 14 + + + + Choose a workflow: + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Begin a New Scan + + + + + + + Continue an Existing Scan + + + + + + + Configure System / Set Default Values + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + + diff --git a/scanengine/motion_worker.py b/scanengine/motion_worker.py new file mode 100644 index 0000000..a3c792a --- /dev/null +++ b/scanengine/motion_worker.py @@ -0,0 +1,426 @@ +""" +Motion Controller Worker Thread + +Handles all motion control operations in a separate thread to keep the UI responsive. +Provides async command queueing and position updates via Qt signals. +""" + +from PyQt6 import QtCore +from hardware.bbd202 import MotionController +import queue +import time +from typing import Optional, Dict, Any + + +class MotionCommand: + """Represents a motion command""" + def __init__(self, cmd_type: str, **kwargs): + self.cmd_type = cmd_type + self.params = kwargs + + +class MotionWorker(QtCore.QObject): + """ + Worker object for handling motion control in a separate thread. + + Signals: + connected: Emitted when controller connects successfully + disconnected: Emitted when controller disconnects + connection_failed: Emitted when connection fails (error_msg: str) + position_updated: Emitted when position changes (x: float, y: float) + homed_status: Emitted with home status (x_homed: bool, y_homed: bool) + move_completed: Emitted when a move completes (axis: str) + error_occurred: Emitted when an error occurs (error_msg: str) + """ + + # Signals + connected = QtCore.pyqtSignal() + disconnected = QtCore.pyqtSignal() + connection_failed = QtCore.pyqtSignal(str) + position_updated = QtCore.pyqtSignal(float, float) # x, y in mm + homed_status = QtCore.pyqtSignal(bool, bool) # x_homed, y_homed + motion_status = QtCore.pyqtSignal(bool, bool) # x_moving, y_moving + move_completed = QtCore.pyqtSignal(str) # axis name + error_occurred = QtCore.pyqtSignal(str) # error message + + def __init__(self): + super().__init__() + self.controller: Optional[MotionController] = None + self.is_connected = False + self.command_queue = queue.Queue() + self.running = True + + # Default parameters + self.jog_speed = 20.0 # mm/s + self.acceleration = 50.0 # mm/s^2 + self.step_size = 1.0 # mm + + # Position tracking + self.last_x = None + self.last_y = None + + # Status tracking + self.last_x_homed = None + self.last_y_homed = None + self.last_x_moving = None + self.last_y_moving = None + + # Position update throttling (active requests can be slow) + self.last_position_update_time = 0 + self.position_update_interval = 0.2 # seconds between position requests + + # Flag to pause polling during scanning (scan worker handles its own position queries) + self.scanning_active = False + + @QtCore.pyqtSlot() + def run(self): + """Main worker loop - processes commands from queue""" + print("Motion worker thread started") + + while self.running: + try: + # Check for commands with timeout to allow periodic position updates + try: + cmd = self.command_queue.get(timeout=0.05) # 50ms timeout + self.process_command(cmd) + except queue.Empty: + pass + + # Periodically update position and status if connected + # Skip updates during scanning - scan worker handles its own position queries + if self.is_connected and self.controller and not self.scanning_active: + self.update_position() + self.update_home_status() + self.update_motion_status() + + except Exception as e: + print(f"Error in motion worker loop: {e}") + self.error_occurred.emit(str(e)) + + # Cleanup on exit + if self.controller: + try: + self.controller.disconnect() + except: + pass + + print("Motion worker thread stopped") + + def process_command(self, cmd: MotionCommand): + """Process a motion command""" + try: + if cmd.cmd_type == 'connect': + self.do_connect() + elif cmd.cmd_type == 'disconnect': + self.do_disconnect() + elif cmd.cmd_type == 'jog': + self.do_jog(cmd.params['axis'], cmd.params['direction']) + elif cmd.cmd_type == 'home': + self.do_home(cmd.params['axis']) + elif cmd.cmd_type == 'set_velocity': + self.do_set_velocity(cmd.params['speed'], cmd.params['accel']) + elif cmd.cmd_type == 'set_step_size': + self.step_size = cmd.params['step_size'] + elif cmd.cmd_type == 'set_axis_enable': + self.do_set_axis_enable(cmd.params['axis'], cmd.params['enabled']) + elif cmd.cmd_type == 'stop': + self.running = False + + except Exception as e: + print(f"Error processing command {cmd.cmd_type}: {e}") + self.error_occurred.emit(f"Command '{cmd.cmd_type}' failed: {str(e)}") + + def do_connect(self): + """Connect to the motion controller""" + try: + self.controller = MotionController() + self.controller.connect() # ACKs are sent reactively when enable_updates=True + + # Enable channels + self.controller.set_channel_enable_state(self.controller.DEST_X_AXIS, True) + self.controller.set_channel_enable_state(self.controller.DEST_Y_AXIS, True) + + # Request status updates for both axes to populate status bits (including homed state) + # This is necessary because status bits are not sent automatically after connect + self.controller.request_status_update(self.controller.DEST_X_AXIS) + self.controller.request_status_update(self.controller.DEST_Y_AXIS) + # Wait a moment for the asynchronous status update responses + time.sleep(0.2) + + # Set initial velocity parameters + for dest in [self.controller.DEST_X_AXIS, self.controller.DEST_Y_AXIS]: + self.controller.set_velocity_params( + dest, + min_velocity=0.0, + acceleration=self.acceleration, + max_velocity=self.jog_speed + ) + + self.is_connected = True + # Force initial updates (they will be emitted because last values are None) + self.update_position() + self.update_home_status() + self.update_motion_status() + self.connected.emit() + + print("Motion controller connected successfully") + + except Exception as e: + print(f"Failed to connect to motion controller: {e}") + self.connection_failed.emit(str(e)) + + def do_disconnect(self): + """Disconnect from the motion controller""" + if self.controller: + try: + self.controller.disconnect() + print("Motion controller disconnected") + except Exception as e: + print(f"Error during disconnect: {e}") + + self.controller = None + self.is_connected = False + self.disconnected.emit() + + def do_jog(self, axis: str, direction: int): + """Execute a jog move""" + if not self.is_connected or not self.controller: + return + + try: + # Determine destination + dest = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS + + # Calculate relative distance + distance = self.step_size * direction + + # Set relative move parameters + self.controller.set_move_rel_params(dest, distance) + + # Execute the move (non-blocking - we don't wait for completion) + # Use a very short timeout since we're doing continuous jogging + self.controller.move_relative(dest, timeout=0.5) + + # Update position + self.update_position() + + self.move_completed.emit(axis) + + except Exception as e: + # Don't emit errors for timeout - that's expected during continuous jog + if "timeout" not in str(e).lower(): + print(f"Jog error: {e}") + self.error_occurred.emit(f"Jog failed: {str(e)}") + + def do_home(self, axis: str): + """Home an axis""" + if not self.is_connected or not self.controller: + return + + try: + dest = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS + + print(f"Homing {axis.upper()} axis...") + self.controller.home_axis(dest, timeout=20.0) + + # Update position and status after homing + self.update_position() + self.update_home_status() + + print(f"{axis.upper()} axis homed successfully") + + except Exception as e: + print(f"Home error: {e}") + self.error_occurred.emit(f"Homing {axis.upper()} failed: {str(e)}") + + def do_set_velocity(self, speed: float, accel: float): + """Set velocity parameters""" + if not self.is_connected or not self.controller: + self.jog_speed = speed + self.acceleration = accel + return + + try: + self.jog_speed = speed + self.acceleration = accel + + for dest in [self.controller.DEST_X_AXIS, self.controller.DEST_Y_AXIS]: + self.controller.set_velocity_params( + dest, + min_velocity=0.0, + acceleration=self.acceleration, + max_velocity=self.jog_speed + ) + + except Exception as e: + print(f"Set velocity error: {e}") + + def do_set_axis_enable(self, axis: str, enabled: bool): + """Enable or disable an axis for manual movement""" + if not self.is_connected or not self.controller: + return + + try: + dest = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS + self.controller.set_channel_enable_state(dest, enabled) + state_str = "enabled" if enabled else "disabled" + print(f"{axis.upper()} axis {state_str}") + + except Exception as e: + print(f"Set axis enable error: {e}") + self.error_occurred.emit(f"Failed to {'enable' if enabled else 'disable'} {axis.upper()} axis: {str(e)}") + + def update_position(self): + """Update current position and emit signal if changed""" + if not self.is_connected or not self.controller: + return + + # Throttle position requests to avoid slowing down the main loop + current_time = time.time() + if current_time - self.last_position_update_time < self.position_update_interval: + return + self.last_position_update_time = current_time + + try: + # Actively request positions from the controller instead of relying on cached values + # This ensures we always have up-to-date position data + # Use longer timeout (1.5s) to accommodate high-speed scanning at 200mm/s + x_pos = self.controller.get_position(self.controller.DEST_X_AXIS, timeout=1.5) + y_pos = self.controller.get_position(self.controller.DEST_Y_AXIS, timeout=1.5) + + if x_pos is not None and y_pos is not None: + # Always emit on first update, or if position changed significantly (> 0.001mm) + if (self.last_x is None or self.last_y is None or + abs(x_pos - self.last_x) > 0.001 or abs(y_pos - self.last_y) > 0.001): + self.last_x = x_pos + self.last_y = y_pos + print(f"Position update: X={x_pos:.3f}mm, Y={y_pos:.3f}mm") + self.position_updated.emit(x_pos, y_pos) + # else: silently skip incomplete position data during busy scanning + + except RuntimeError as e: + # RuntimeError indicates actual hardware error (overtemp, encoder fault, etc.) + print(f"CRITICAL: Motor error detected: {e}") + self.error_occurred.emit(str(e)) + # Stop requesting position updates to avoid spam + self.is_connected = False + except Exception as e: + print(f"Error updating position: {e}") + import traceback + traceback.print_exc() + + def update_home_status(self): + """Update home status and emit signal if changed""" + if not self.is_connected or not self.controller: + return + + try: + x_homed = self.controller.is_homed_x + y_homed = self.controller.is_homed_y + + # Only emit if status changed + if x_homed != self.last_x_homed or y_homed != self.last_y_homed: + self.last_x_homed = x_homed + self.last_y_homed = y_homed + self.homed_status.emit(x_homed, y_homed) + + except Exception as e: + print(f"Error updating home status: {e}") + import traceback + traceback.print_exc() + + def update_motion_status(self): + """Update motion status and emit signal if changed. + + NOTE: On BBD202 firmware v2.1.5, the is_in_motion_x/y properties + do NOT reliably detect motion - they may always return False even + during active movement. This is a known firmware limitation. + + For scan execution, use the ScanWorker.move_and_wait() method which + uses position-based motion detection instead of status bits. + + This UI status display is best-effort only. + """ + if not self.is_connected or not self.controller: + return + + try: + # Actively poll for status updates + self.controller.poll_status() + + # Check for any error conditions + error_msg = self.controller.check_for_errors() + if error_msg: + print(f"Motor error detected: {error_msg}") + self.error_occurred.emit(error_msg) + self.controller.clear_last_error() + + # Read the cached status (may not accurately reflect motion on some firmware) + x_moving = self.controller.is_in_motion_x + y_moving = self.controller.is_in_motion_y + + # Also check if there are pending moves (more reliable) + if self.controller.is_move_pending(): + # If there are pending moves, we're likely still moving + # This provides a backup indication when status bits fail + pending = self.controller.get_pending_targets() + if self.controller.DEST_X_AXIS in pending: + x_moving = True + if self.controller.DEST_Y_AXIS in pending: + y_moving = True + + # Only emit if status changed + if x_moving != self.last_x_moving or y_moving != self.last_y_moving: + self.last_x_moving = x_moving + self.last_y_moving = y_moving + self.motion_status.emit(x_moving, y_moving) + + except Exception as e: + print(f"Error updating motion status: {e}") + import traceback + traceback.print_exc() + + # Slot methods for queuing commands + @QtCore.pyqtSlot() + def queue_connect(self): + """Queue a connect command""" + self.command_queue.put(MotionCommand('connect')) + + @QtCore.pyqtSlot() + def queue_disconnect(self): + """Queue a disconnect command""" + self.command_queue.put(MotionCommand('disconnect')) + + @QtCore.pyqtSlot(str, int) + def queue_jog(self, axis: str, direction: int): + """Queue a jog command""" + self.command_queue.put(MotionCommand('jog', axis=axis, direction=direction)) + + @QtCore.pyqtSlot(str) + def queue_home(self, axis: str): + """Queue a home command""" + self.command_queue.put(MotionCommand('home', axis=axis)) + + @QtCore.pyqtSlot(float, float) + def queue_set_velocity(self, speed: float, accel: float): + """Queue a set velocity command""" + self.command_queue.put(MotionCommand('set_velocity', speed=speed, accel=accel)) + + @QtCore.pyqtSlot(float) + def queue_set_step_size(self, step_size: float): + """Queue a set step size command""" + self.command_queue.put(MotionCommand('set_step_size', step_size=step_size)) + + @QtCore.pyqtSlot(str, bool) + def queue_set_axis_enable(self, axis: str, enabled: bool): + """Queue a command to enable or disable an axis""" + self.command_queue.put(MotionCommand('set_axis_enable', axis=axis, enabled=enabled)) + + @QtCore.pyqtSlot() + def stop(self): + """Stop the worker thread""" + # Set running to False immediately so the main loop can exit + # even if it's blocked waiting for a response from the controller + self.running = False + # Also queue a stop command to ensure the command_queue.get() returns + self.command_queue.put(MotionCommand('stop')) diff --git a/scanengine/new_scan_wizard.ui b/scanengine/new_scan_wizard.ui new file mode 100644 index 0000000..59910a3 --- /dev/null +++ b/scanengine/new_scan_wizard.ui @@ -0,0 +1,1113 @@ + + + Form + + + + 0 + 0 + 879 + 805 + + + + Form + + + + + + + + 0 + + + + + + + + + + 28 + + + + Enter Scan Metadata: + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + 12 + + + + _SS_NNNN.wfm + + + + + + + + + Standalone Scan + + + + + + + Cooperative Scan + + + + + + + + + mm + + + + + + + + 12 + + + + Row Spacing: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 12 + + + + Number of +Angles: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 12 + + + + Waveform File +Prefix: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 100 + 30 + + + + + + + + + + + + 12 + + + + Data +Directory: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + + + + + 100 + 16777215 + + + + Browse + + + + + + + + + + + 12 + + + + Scan Friendly +Name: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 100 + 30 + + + + + + + + + 12 + + + + Scan Type: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Qt::Orientation::Horizontal + + + QSizePolicy::Policy::Fixed + + + + 200 + 0 + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Number of +Cycles/Layers: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 100 + 16777215 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + + 14 + + + + 000.00 + + + + + + + + 24 + + + + 000 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + 100 + 16777215 + + + + + + + + mm + + + + + + + + + + + Jog Up + + + + + + + + Noto Sans + + + + Axis +1 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Jog Down + + + + + + + + + CH A Voltage [mV]: + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + Jog Up + + + + + + + + Noto Sans + + + + Axes +2+3 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Jog Down + + + + + + + + + + + Enable Helios (Focusing Mode) + + + true + + + QPushButton:checked { background-color: #ff4444; color: white; font-weight: bold; } + + + + + + + Jog Stage + + + + + + + + 10 + 75 + true + + + + Camera Controls + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + Exposure: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignVCenter + + + + + + + 1 + + + 100 + + + 10 + + + Qt::Orientation::Horizontal + + + QSlider::TickPosition::TicksBelow + + + 10 + + + + + + + + 50 + 0 + + + + 10 ms + + + + + + + + + + + Gain: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignVCenter + + + + + + + 0 + + + 100 + + + 0 + + + Qt::Orientation::Horizontal + + + QSlider::TickPosition::TicksBelow + + + 10 + + + + + + + + 50 + 0 + + + + 0 + + + + + + + + + + + Wobble Distance: + + + + + + + + 24 + + + + 000 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Stage X Coordinate: + + + + + + + + 400 + 400 + + + + + + + + + 24 + + + + 000 + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 14 + + + + 000.00 + + + + + + + CH B Voltage [mV] + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Stage Y Coordinate: + + + + + + + + 28 + + + + Align Microscope: + + + + + + + Difference (A - B) + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Unlock Stage (Disables Wobble) + + + + + + + Align Y Axis + + + true + + + + + + + Align X Axis + + + true + + + true + + + + + + + Toggle Wobble + + + true + + + + + + + + + + + + + + + + 100 + 16777215 + + + + + + + + Y-Delta: + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + 100 + 16777215 + + + + + + + + X-Start: + + + + + + + Y-Start: + + + + + + + + 28 + + + + Define Scan Area: + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + 100 + 16777215 + + + + Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft + + + + + + + X-Delta: + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 100 + 16777215 + + + + + + + + Draw Mode + + + + + + + Clear Scanning Bounds + + + + + + + + 300 + 300 + + + + + 400 + 400 + + + + + + + + Do LowRes Survey + + + + + + + Refine Survey + + + + + + + + + + + + + + + Scan Area: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Pixel Size: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Scan Coordinates: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Scan Area: + + + + + + + + 28 + + + + Scan Summary: + + + + + + + Pixel Size: + + + + + + + + + + + + + + Start Scanning + + + + + + + Scan Saved At: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Scan Friendly Name: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + Scan Area: + + + + + + + Scan Saved At: + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Scan Friendly Name: + + + + + + + + + + + + + + + Cancel + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + < Back + + + + + + + Next > + + + + + + + + + + + + diff --git a/scanengine/options.ui b/scanengine/options.ui new file mode 100644 index 0000000..e121dca --- /dev/null +++ b/scanengine/options.ui @@ -0,0 +1,743 @@ + + + Dialog + + + + 0 + 0 + 681 + 471 + + + + + Bahnschrift + 12 + + + + Dialog + + + + + + + + 0 + + + + Detection Laser + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Scanning Power [mW]: + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Test Connection: + + + + + + + ??? + + + + + + + Query Laser / Test Connection + + + + + + + + + + + + + Laser SN: + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Laser Model: + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + + Interlock State: + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Keyswitch State: + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + + Main Heatsink Temp + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + ETA Temp + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + + + + + Generation Laser + + + + + + + + + + Communication Port: + + + + + + + + + + Autodetect + + + + + + + + + + 11 + 75 + true + + + + Focusing Parameters: + + + + + + + + + Focusing Frequency [Hz]: + + + + + + + + + + + + + + Focusing Pump Current [mA]: + + + + + + + + + + + + Qt::Orientation::Vertical + + + QSizePolicy::Policy::Fixed + + + + 20 + 20 + + + + + + + + + 11 + 75 + true + + + + Scanning Parameters: + + + + + + + + + Scanning Frequency [Hz]: + + + + + + + + + + + + + + Scanning Pump Current [mA]: + + + + + + + + + + + + + + Effective Scan Direction Pixel Size: + + + + + + + mm px + + + + + + + + + + + Reset Laser (Required after interlock enable) + + + + + + + + + + + + Scanning Stage + + + + + + + + + + Scan Velocity [mm/s]: + + + + + + + + + + + + + + Scan Acceleration [mm/s2]: + + + + + + + + + + + + + + X-Axis Trigger Mode + + + + + + + + + + + + + + Y-Axis Trigger Mode + + + + + + + + + + + + + + Optical Axis X Coordinate [mm]: + + + + + + + + + + Optical Axis Y Coordinate [mm]: + + + + + + + + + + + + + + + T3R + + + + + + + + + + Comminucation Port: + + + + + + + + + + Autodetect + + + + + + + + + + + Test Connection + + + + + + + + + + + + + Firmware Version + + + Qt::AlignmentFlag::AlignCenter + + + + + + + FPGA Present? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Homed Status + + + Qt::AlignmentFlag::AlignCenter + + + + + + + FPGA MultiDivider +Enabled? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + FPGA RowPacking +Enabled? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + ??? + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + + + + + Oscilloscope + + + + + + + + + + Oscilloscope Socket Address: + + + + + + + + + + + + + + Scratch Directory: + + + + + + + + + + Browse + + + + + + + + + + + Save to PC + + + + + + + Save to Oscilloscope + + + + + + + + + + + Test Connection + + + + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Update Configuration + + + + + + + Cancel + + + + + + + + + + + + diff --git a/scanning/__init__.py b/scanning/__init__.py new file mode 100644 index 0000000..c03dc67 --- /dev/null +++ b/scanning/__init__.py @@ -0,0 +1,3 @@ +"""Scan planning and modeling modules""" +from .sc3_scan_model import SC3ScanModel +from .stage_scan_plan_generator import * diff --git a/scanning/sc3_scan_model.py b/scanning/sc3_scan_model.py new file mode 100644 index 0000000..53dae6a --- /dev/null +++ b/scanning/sc3_scan_model.py @@ -0,0 +1,638 @@ +""" +Scan Model Class. +Holds the scan configuration, and computes the +required start and end points for various specified angles. +Python implementation of SC3ScanModel.cs +""" + +import math +from decimal import Decimal, InvalidOperation +from typing import List, Optional +import csv + + +class SC3ScanModel: + """ + Scan Model for generating scan paths and rotated scans. + Manages scan configuration and computes scan coordinates at various angles. + """ + + def __init__(self): + # Private variables + self._x_origin: Decimal = Decimal('0.0') + self._y_origin: Decimal = Decimal('0.0') + self._x_delta: Decimal = Decimal('0.0') + self._y_delta: Decimal = Decimal('0.0') + self._row_spacing: Decimal = Decimal('0.0') + self._laser_frequency: Decimal = Decimal('2000.0') # Default: 2000 Hz + self._scan_velocity: Decimal = Decimal('100.0') # Default: 100 mm/s + self._scan_acceleration: Decimal = Decimal('0.0') + self._scan_angles: int = 0 + self._points_required: int = 0 + self._rows_required: int = 0 + self._points_per_line: int = 0 + + # Optical axis centerline in stage coordinates + # Stage: MLS203-1 + self._optical_x_origin: Decimal = Decimal('55.0') + self._optical_y_origin: Decimal = Decimal('37.5') + + # Data storage + self._scan_coordinates: List[List[Decimal]] = [] + self._scan_velocities: List[List[Decimal]] = [] + self._scan_accelerations: List[List[Decimal]] = [] + self._rotated_coordinates: List[List[List[Decimal]]] = [] + + # Constants + self._deg2rad: float = math.pi / 180.0 + + # Properties + @property + def x_origin(self) -> Decimal: + """X coordinate of scan origin (mm)""" + return self._x_origin + + @x_origin.setter + def x_origin(self, value: Decimal): + try: + self._x_origin = Decimal(str(value)) + except (ValueError, InvalidOperation) as e: + raise ValueError(f"Invalid x_origin value: {value}") from e + + @property + def y_origin(self) -> Decimal: + """Y coordinate of scan origin (mm)""" + return self._y_origin + + @y_origin.setter + def y_origin(self, value: Decimal): + try: + self._y_origin = Decimal(str(value)) + except (ValueError, InvalidOperation) as e: + raise ValueError(f"Invalid y_origin value: {value}") from e + + @property + def x_delta(self) -> Decimal: + """Total X distance to scan (mm)""" + return self._x_delta + + @x_delta.setter + def x_delta(self, value: Decimal): + try: + val = Decimal(str(value)) + if val < 0: + raise ValueError("x_delta must be non-negative") + self._x_delta = val + self.calculate_points_per_line() + self.calculate_points_required() + except (ValueError, InvalidOperation) as e: + raise ValueError(f"Invalid x_delta value: {value}") from e + + @property + def y_delta(self) -> Decimal: + """Total Y distance to scan (mm)""" + return self._y_delta + + @y_delta.setter + def y_delta(self, value: Decimal): + try: + val = Decimal(str(value)) + if val < 0: + raise ValueError("y_delta must be non-negative") + self._y_delta = val + self.calculate_rows_required() + self.calculate_points_required() + except (ValueError, InvalidOperation) as e: + raise ValueError(f"Invalid y_delta value: {value}") from e + + @property + def row_spacing(self) -> Decimal: + """Spacing between scan rows (mm)""" + return self._row_spacing + + @row_spacing.setter + def row_spacing(self, value: Decimal): + try: + val = Decimal(str(value)) + if val < 0: + raise ValueError("row_spacing must be non-negative") + self._row_spacing = val + self.calculate_rows_required() + self.calculate_points_required() + except (ValueError, InvalidOperation) as e: + raise ValueError(f"Invalid row_spacing value: {value}") from e + + @property + def laser_frequency(self) -> Decimal: + """Laser pulse frequency (Hz)""" + return self._laser_frequency + + @laser_frequency.setter + def laser_frequency(self, value: Decimal): + try: + val = Decimal(str(value)) + if val <= 0: + raise ValueError("laser_frequency must be positive") + self._laser_frequency = val + self.calculate_points_per_line() + self.calculate_points_required() + except (ValueError, InvalidOperation) as e: + raise ValueError(f"Invalid laser_frequency value: {value}") from e + + @property + def scan_velocity(self) -> Decimal: + """Scan velocity (mm/s)""" + return self._scan_velocity + + @scan_velocity.setter + def scan_velocity(self, value: Decimal): + try: + val = Decimal(str(value)) + if val <= 0: + raise ValueError("scan_velocity must be positive") + self._scan_velocity = val + self.calculate_points_per_line() + self.calculate_points_required() + except (ValueError, InvalidOperation) as e: + raise ValueError(f"Invalid scan_velocity value: {value}") from e + + @property + def scan_acceleration(self) -> Decimal: + """Scan acceleration (mm/s²)""" + return self._scan_acceleration + + @scan_acceleration.setter + def scan_acceleration(self, value: Decimal): + try: + val = Decimal(str(value)) + if val < 0: + raise ValueError("scan_acceleration must be non-negative") + self._scan_acceleration = val + except (ValueError, InvalidOperation) as e: + raise ValueError(f"Invalid scan_acceleration value: {value}") from e + + @property + def scan_angles(self) -> int: + """Number of scan angles to compute""" + return self._scan_angles + + @scan_angles.setter + def scan_angles(self, value: int): + if value < 0: + raise ValueError("scan_angles must be non-negative") + self._scan_angles = value + # Only compute rotated scans if we have base scan coordinates + if self._scan_coordinates: + self.compute_rotated_scans() + + @property + def points_required(self) -> int: + """Total number of points required for a single angle scan (computed)""" + return self._points_required + + @property + def rows_required(self) -> int: + """Number of rows required for the scan (computed)""" + return self._rows_required + + @property + def points_per_line(self) -> int: + """Number of points per scan line (computed)""" + return self._points_per_line + + @property + def scan_coordinates(self) -> List[List[Decimal]]: + """List of scan coordinates [x_start, y_start, x_end, y_end] in mm""" + return self._scan_coordinates + + @property + def scan_velocities(self) -> List[List[Decimal]]: + """List of velocity vectors [vx, vy] in mm/s for each angle""" + return self._scan_velocities + + @property + def scan_accelerations(self) -> List[List[Decimal]]: + """List of acceleration vectors [ax, ay] in mm/s² for each angle""" + return self._scan_accelerations + + @property + def rotated_coordinates(self) -> List[List[List[Decimal]]]: + """List of rotated scan coordinates for each angle in mm""" + return self._rotated_coordinates + + @property + def optical_x_origin(self) -> Decimal: + """X coordinate of optical axis origin in mm (read-only, MLS203-1 stage)""" + return self._optical_x_origin + + @property + def optical_y_origin(self) -> Decimal: + """Y coordinate of optical axis origin in mm (read-only, MLS203-1 stage)""" + return self._optical_y_origin + + # Calculation Methods + def calculate_points_per_line(self): + """ + Calculates the number of data points per scan line based on + x_delta, scan_velocity, and laser_frequency. + + Formula: points = (distance / velocity) * frequency + """ + if self._scan_velocity != 0: + self._points_per_line = int( + (self._x_delta / self._scan_velocity) * self._laser_frequency + ) + + def calculate_points_required(self): + """ + Calculates the total number of data points for a complete single-angle scan. + Also triggers computation of the zero-angle scan coordinates. + + Formula: total_points = points_per_line * rows_required + """ + if self._rows_required != 0: + self._points_required = self._points_per_line * self._rows_required + self.compute_zero_scan() + + def calculate_rows_required(self): + """ + Calculates the number of scan rows needed based on y_delta and row_spacing. + + Formula: rows = ceil(y_delta / row_spacing) + """ + if self._row_spacing == 0: + self._rows_required = 0 + else: + self._rows_required = int(math.ceil(self._y_delta / self._row_spacing)) + + def compute_zero_scan(self): + """ + Computes the zero-angle (reference) scan coordinates. + Each coordinate is [x_start, y_start, x_end, y_end]. + """ + # Ignore the zero-row case + if self._rows_required == 0: + return + + y_offset = Decimal('0.0') + self._scan_coordinates.clear() + + for row in range(self._rows_required + 1): + # Calculate x/y origin/delta for each needed row + y_offset = Decimal(row) * self._row_spacing + + coords = [ + self._x_origin, # x_start + self._y_origin + y_offset, # y_start + self._x_origin + self._x_delta, # x_end + self._y_origin + y_offset # y_end (same as y_start for horizontal scan) + ] + self._scan_coordinates.append(coords) + + def _rotate_point(self, x: Decimal, y: Decimal, cosine: Decimal, sine: Decimal) -> tuple: + """ + Rotate a point around the optical axis origin. + + Args: + x, y: Point coordinates to rotate + cosine, sine: Precomputed cos and sin of rotation angle + + Returns: + Tuple of (rotated_x, rotated_y) + """ + # Rotation transformation: + # Xr = (X - Xo)*cos(a) + (Y - Yo)*sin(a) + Xo + # Yr = -(X - Xo)*sin(a) + (Y - Yo)*cos(a) + Yo + x_rot = ((x - self._optical_x_origin) * cosine + + (y - self._optical_y_origin) * sine + + self._optical_x_origin) + y_rot = (-(x - self._optical_x_origin) * sine + + (y - self._optical_y_origin) * cosine + + self._optical_y_origin) + return (x_rot, y_rot) + + def _compute_rotated_aoi_bbox(self, angle_rad: float) -> tuple: + """ + Compute the bounding box of the rotated area of interest. + + Rotates the four corners of the AoI rectangle and finds the + min/max extents to create a bounding box. + + Args: + angle_rad: Rotation angle in radians + + Returns: + Tuple of (min_x, min_y, max_x, max_y) as Decimals + """ + cosine = Decimal(str(math.cos(angle_rad))) + sine = Decimal(str(math.sin(angle_rad))) + + # Define the four corners of the AoI rectangle + corners = [ + (self._x_origin, self._y_origin), + (self._x_origin + self._x_delta, self._y_origin), + (self._x_origin + self._x_delta, self._y_origin + self._y_delta), + (self._x_origin, self._y_origin + self._y_delta) + ] + + # Rotate all corners + rotated_corners = [] + for x, y in corners: + x_rot, y_rot = self._rotate_point(x, y, cosine, sine) + rotated_corners.append((x_rot, y_rot)) + + # Find bounding box extents + x_coords = [corner[0] for corner in rotated_corners] + y_coords = [corner[1] for corner in rotated_corners] + + return (min(x_coords), min(y_coords), max(x_coords), max(y_coords)) + + def compute_rotated_scans(self): + """ + Computes rotated scan coordinates by rotating the area of interest (AoI) + and generating horizontal (+x direction) scans through the bounding box + of the rotated AoI. + + The rotation covers 0 to 180 degrees with spacing determined by scan_angles. + For each angle: + 1. Rotate the AoI rectangle around the optical axis origin + 2. Compute the bounding box of the rotated rectangle + 3. Generate horizontal scan lines through the bounding box + """ + if self._rows_required == 0: + return + + # Compute spacing between scans + if self._scan_angles == 0: + angle_spacing = 180 + else: + angle_spacing = 180 / self._scan_angles + + self._rotated_coordinates.clear() + + # Iterate through each required angle from 0 to 180 degrees + i = 0 + while i < 180: + current_angle_radians = i * (math.pi / 180.0) + + # Get bounding box of rotated AoI + min_x, min_y, max_x, max_y = self._compute_rotated_aoi_bbox(current_angle_radians) + + # Calculate the y extent of the bounding box + y_extent = max_y - min_y + + # Determine number of rows needed for this bounding box + if self._row_spacing == 0: + num_rows = 0 + else: + num_rows = int(math.ceil(y_extent / self._row_spacing)) + + temp_list = [] + + # Generate horizontal scan lines through the bounding box + for row in range(num_rows + 1): + y_offset = Decimal(row) * self._row_spacing + y_pos = min_y + y_offset + + # Create horizontal scan line at this y position + scan_line = [ + min_x, # x_start + y_pos, # y_start + max_x, # x_end + y_pos # y_end (same as y_start for horizontal scan) + ] + temp_list.append(scan_line) + + self._rotated_coordinates.append(temp_list) + i += int(round(angle_spacing)) + + def compute_kinematics(self, offset: int = 0): + """ + Computes velocity and acceleration component vectors for each scan angle. + + For each angle, decomposes the scalar velocity and acceleration into + X and Y components based on the scan direction angle. + + Args: + offset: Angle offset in degrees (default: 0) + """ + if self._scan_angles == 0: + scan_increment = 180 + else: + scan_increment = 180 // self._scan_angles + + self._scan_velocities.clear() + self._scan_accelerations.clear() + + for i in range(self._scan_angles): + deg_angle = scan_increment * i + offset + angle_rad = deg_angle * self._deg2rad + + # Compute velocity components: V = V_mag * [cos(θ), sin(θ)] + velocities = [ + Decimal(str(math.cos(angle_rad))) * self._scan_velocity, + Decimal(str(math.sin(angle_rad))) * self._scan_velocity + ] + + # Compute acceleration components: A = A_mag * [cos(θ), sin(θ)] + accels = [ + Decimal(str(math.cos(angle_rad))) * self._scan_acceleration, + Decimal(str(math.sin(angle_rad))) * self._scan_acceleration + ] + + self._scan_velocities.append(velocities) + self._scan_accelerations.append(accels) + + # Export Methods + def export_zero_scan_csv(self, filename: Optional[str] = None) -> str: + """ + Export the zero-angle scan coordinates to a CSV file. + + Args: + filename: Output filename. If None, generates from row count. + + Returns: + The filename that was written + + Raises: + ValueError: If no scan coordinates have been computed + IOError: If file cannot be written + """ + if not self._scan_coordinates: + raise ValueError("No scan coordinates available. Configure scan parameters first.") + + if filename is None: + filename = f"scantest-{self._rows_required}rows.csv" + + try: + with open(filename, 'w', newline='') as f: + writer = csv.writer(f) + for coords in self._scan_coordinates: + writer.writerow([str(c) for c in coords]) + except IOError as e: + raise IOError(f"Failed to write file {filename}: {e}") from e + + return filename + + def export_rotated_scan_csv(self, angle_index: int, filename: Optional[str] = None) -> str: + """ + Export a specific rotated scan to CSV. + + Args: + angle_index: Index of the angle to export (0-based) + filename: Output filename. If None, generates from angle and row count. + + Returns: + The filename that was written + + Raises: + ValueError: If angle_index is invalid or no rotated coordinates exist + IOError: If file cannot be written + """ + if not self._rotated_coordinates: + raise ValueError("No rotated coordinates available. Set scan_angles first.") + + if angle_index < 0 or angle_index >= len(self._rotated_coordinates): + raise ValueError( + f"Invalid angle_index {angle_index}. Must be 0-{len(self._rotated_coordinates)-1}" + ) + + if filename is None: + angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180) + filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv" + + try: + with open(filename, 'w', newline='') as f: + writer = csv.writer(f) + for coords in self._rotated_coordinates[angle_index]: + writer.writerow([str(c) for c in coords]) + except IOError as e: + raise IOError(f"Failed to write file {filename}: {e}") from e + + return filename + + def export_all_rotated_scans_csv(self, output_dir: str = ".") -> List[str]: + """ + Export all rotated scans to separate CSV files. + + Args: + output_dir: Directory to write files to (default: current directory) + + Returns: + List of filenames that were written + + Raises: + ValueError: If no rotated coordinates exist + IOError: If files cannot be written + """ + if not self._rotated_coordinates: + raise ValueError("No rotated coordinates available. Set scan_angles first.") + + import os + filenames = [] + + for angle_index in range(len(self._rotated_coordinates)): + angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180) + filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv" + filepath = os.path.join(output_dir, filename) + + try: + with open(filepath, 'w', newline='') as f: + writer = csv.writer(f) + for coords in self._rotated_coordinates[angle_index]: + writer.writerow([str(c) for c in coords]) + filenames.append(filepath) + except IOError as e: + raise IOError(f"Failed to write file {filepath}: {e}") from e + + return filenames + + def export_kinematics_csv(self, filename: Optional[str] = None) -> str: + """ + Export velocity and acceleration data to CSV. + Format: vx, vy, ax, ay for each angle. + + Args: + filename: Output filename. If None, generates from row count. + + Returns: + The filename that was written + + Raises: + ValueError: If no kinematics data has been computed + IOError: If file cannot be written + """ + if not self._scan_velocities or not self._scan_accelerations: + raise ValueError("No kinematics data available. Call compute_kinematics() first.") + + if filename is None: + filename = f"kinematics-{self._rows_required}rows.csv" + + try: + with open(filename, 'w', newline='') as f: + writer = csv.writer(f) + # Optional: write header + writer.writerow(['vx', 'vy', 'ax', 'ay']) + for i in range(len(self._scan_velocities)): + row = [ + str(self._scan_velocities[i][0]), + str(self._scan_velocities[i][1]), + str(self._scan_accelerations[i][0]), + str(self._scan_accelerations[i][1]) + ] + writer.writerow(row) + except IOError as e: + raise IOError(f"Failed to write file {filename}: {e}") from e + + return filename + + # Utility Methods + def get_angle_list(self) -> List[int]: + """ + Get the list of scan angles in degrees. + + Returns: + List of angles in degrees for the configured scan_angles + """ + if self._scan_angles == 0: + return [] + + scan_increment = 180 // self._scan_angles + return [scan_increment * i for i in range(self._scan_angles)] + + def get_scan_info(self) -> dict: + """ + Get a dictionary with current scan configuration and computed values. + + Returns: + Dictionary containing scan parameters and computed values + """ + return { + 'x_origin': float(self._x_origin), + 'y_origin': float(self._y_origin), + 'x_delta': float(self._x_delta), + 'y_delta': float(self._y_delta), + 'row_spacing': float(self._row_spacing), + 'laser_frequency': float(self._laser_frequency), + 'scan_velocity': float(self._scan_velocity), + 'scan_acceleration': float(self._scan_acceleration), + 'scan_angles': self._scan_angles, + 'points_per_line': self._points_per_line, + 'rows_required': self._rows_required, + 'points_required': self._points_required, + 'optical_x_origin': float(self._optical_x_origin), + 'optical_y_origin': float(self._optical_y_origin), + 'num_scan_coordinates': len(self._scan_coordinates), + 'num_rotated_angles': len(self._rotated_coordinates), + 'angle_list': self.get_angle_list(), + } + + def __repr__(self) -> str: + """String representation of the scan model.""" + return ( + f"SC3ScanModel(" + f"origin=({self._x_origin},{self._y_origin}), " + f"delta=({self._x_delta},{self._y_delta}), " + f"rows={self._rows_required}, " + f"angles={self._scan_angles})" + ) diff --git a/nuescan/stage_scan_plan_generator.py b/scanning/stage_scan_plan_generator.py similarity index 100% rename from nuescan/stage_scan_plan_generator.py rename to scanning/stage_scan_plan_generator.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..1bcdf6b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test modules for ScanEngine-3""" diff --git a/tests/test_bbd202_diagnostic.py b/tests/test_bbd202_diagnostic.py new file mode 100644 index 0000000..c3ed439 --- /dev/null +++ b/tests/test_bbd202_diagnostic.py @@ -0,0 +1,569 @@ +""" +BBD202 Stage Diagnostic Tests + +Diagnostic tests for the Thorlabs MLS203/BBD202 motion controller to: +1. Check if axes are properly homed +2. Move in a 15mm square pattern while outputting position data +3. Detect stage hangs through position monitoring +4. Output response information for debugging +""" + +import sys +import time +from typing import Optional +from hardware.bbd202 import MotionController, MotorStatusBits + + +def format_status_bits(bits: Optional[int]) -> str: + """Format status bits as a readable string with key flags.""" + if bits is None: + return "None (no status received)" + + flags = [] + status = MotorStatusBits(bits) + + # Key flags to check + if MotorStatusBits.HOMED in status: + flags.append("HOMED") + if MotorStatusBits.HOMING in status: + flags.append("HOMING") + if MotorStatusBits.ENABLED in status: + flags.append("ENABLED") + if MotorStatusBits.SETTLED in status: + flags.append("SETTLED") + if MotorStatusBits.TRACKING in status: + flags.append("TRACKING") + if MotorStatusBits.INMOTIONCW in status: + flags.append("INMOTIONCW") + if MotorStatusBits.INMOTIONCCW in status: + flags.append("INMOTIONCCW") + if MotorStatusBits.POWEROK in status: + flags.append("POWEROK") + if MotorStatusBits.ERROR in status: + flags.append("ERROR") + if MotorStatusBits.POSITIONERROR in status: + flags.append("POSITIONERROR") + if MotorStatusBits.OVERTEMP in status: + flags.append("OVERTEMP") + if MotorStatusBits.COMMUTATIONERROR in status: + flags.append("COMMUTATIONERROR") + + flag_str = ", ".join(flags) if flags else "NO_FLAGS" + return f"0x{bits:08X} [{flag_str}]" + + +def test_homing_status(mc: MotionController) -> bool: + """ + Test 1: Check if both axes are properly homed. + + Returns True if both axes are homed, False otherwise. + """ + print("\n" + "="*70) + print("TEST 1: Checking Homing Status") + print("="*70) + + # Request fresh status from both axes + print("\nRequesting status update from both axes...") + mc.request_status_update(mc.DEST_X_AXIS) + mc.request_status_update(mc.DEST_Y_AXIS) + time.sleep(0.2) # Allow time for response + + # Also poll status to ensure we have latest + mc.poll_status() + time.sleep(0.1) + + # Check X-axis + x_bits = mc.status_bits_x + x_homed = mc.is_homed_x + print(f"\nX-Axis Status:") + print(f" Raw status bits: {format_status_bits(x_bits)}") + print(f" is_homed_x: {x_homed}") + + # Check Y-axis + y_bits = mc.status_bits_y + y_homed = mc.is_homed_y + print(f"\nY-Axis Status:") + print(f" Raw status bits: {format_status_bits(y_bits)}") + print(f" is_homed_y: {y_homed}") + + # Check for errors + last_err = mc.last_error + if last_err: + print(f"\n** Last Error: {last_err}") + + error_check = mc.check_for_errors() + if error_check: + print(f"** Error condition detected: {error_check}") + + # Summary + print(f"\n--- Homing Status Summary ---") + if x_homed and y_homed: + print("PASS: Both axes are homed") + return True + else: + print("FAIL: One or both axes are NOT homed") + if not x_homed: + print(" -> X-axis needs homing") + if not y_homed: + print(" -> Y-axis needs homing") + return False + + +def test_square_pattern(mc: MotionController, side_length: float = 15.0) -> bool: + """ + Test 2: Move the stage in a square pattern. + + Moves in a 15mm square starting from current position, outputting + position data at each step to detect hangs. + + Pattern: Start -> +X -> +Y -> -X -> -Y (back to start) + + Uses the new wait_until_settled() method for reliable motion detection. + """ + print("\n" + "="*70) + print(f"TEST 2: Square Pattern Movement ({side_length}mm)") + print("="*70) + print("Using wait_until_settled() for motion detection") + + # Get starting position + mc.poll_positions() + time.sleep(0.1) + start_x = mc.position_x + start_y = mc.position_y + + if start_x is None or start_y is None: + print("ERROR: Could not get initial position") + return False + + print(f"\nStarting position: X={start_x:.4f}mm, Y={start_y:.4f}mm") + + # Define the square corners (relative to start) + corners = [ + (start_x + side_length, start_y), # Move +X + (start_x + side_length, start_y + side_length), # Move +Y + (start_x, start_y + side_length), # Move -X + (start_x, start_y), # Move -Y (back to start) + ] + + corner_names = ["+X edge", "+Y edge (diagonal)", "-X edge", "Return to start"] + + all_passed = True + + for i, (target_x, target_y) in enumerate(corners): + print(f"\n--- Move {i+1}/4: {corner_names[i]} ---") + print(f"Target: X={target_x:.4f}mm, Y={target_y:.4f}mm") + + # Clear any previous error + mc.clear_last_error() + + # Record start time + move_start = time.time() + + # Issue move command (non-blocking) + mc.move_to_fast(x=target_x, y=target_y) + + # Use poll_until_idle() in a loop to show progress + print(f"\n{'Time':>8} {'X_pos':>12} {'Y_pos':>12} {'X_err':>10} {'Y_err':>10} {'Pending'}") + print("-" * 70) + + sample_count = 0 + while not mc.poll_until_idle(tolerance=0.005, timeout=0.05): + elapsed = time.time() - move_start + + curr_x = mc.position_x + curr_y = mc.position_y + + if curr_x is not None and curr_y is not None: + x_err = curr_x - target_x + y_err = curr_y - target_y + + # Print every 5th sample + if sample_count % 5 == 0: + pending = mc.get_pending_targets() + pending_str = ", ".join([f"{'X' if d==0x21 else 'Y'}" for d in pending.keys()]) + print(f"{elapsed:>7.3f}s {curr_x:>12.4f} {curr_y:>12.4f} {x_err:>+10.4f} {y_err:>+10.4f} {pending_str:>8}") + + sample_count += 1 + + # Timeout check + if elapsed > 30.0: + print(f"\n** TIMEOUT after 30s - stage may be hung!") + all_passed = False + mc.clear_pending_moves() + break + + # Move complete - show final status + elapsed = time.time() - move_start + curr_x = mc.position_x + curr_y = mc.position_y + + if curr_x is not None and curr_y is not None: + x_err = curr_x - target_x + y_err = curr_y - target_y + print(f"{elapsed:>7.3f}s {curr_x:>12.4f} {curr_y:>12.4f} {x_err:>+10.4f} {y_err:>+10.4f} ** DONE **") + print(f"\nMove completed in {elapsed:.3f}s") + print(f"Final position: X={curr_x:.4f}mm, Y={curr_y:.4f}mm") + print(f"Position error: X={abs(x_err)*1000:.1f}um, Y={abs(y_err)*1000:.1f}um") + + # Check for errors after move + err = mc.check_for_errors() + if err: + print(f"** Error after move: {err}") + all_passed = False + + last_err = mc.last_error + if last_err: + print(f"** Last error: {last_err}") + all_passed = False + + # Short delay between moves + time.sleep(0.1) + + # Final summary + print(f"\n--- Square Pattern Summary ---") + mc.poll_positions() + time.sleep(0.1) + final_x = mc.position_x + final_y = mc.position_y + if final_x is not None and final_y is not None: + total_x_err = abs(final_x - start_x) + total_y_err = abs(final_y - start_y) + print(f"Final position: X={final_x:.4f}mm, Y={final_y:.4f}mm") + print(f"Start position: X={start_x:.4f}mm, Y={start_y:.4f}mm") + print(f"Return error: X={total_x_err*1000:.1f}um, Y={total_y_err*1000:.1f}um") + + if total_x_err > 0.05 or total_y_err > 0.05: + print("FAIL: Did not return to start position accurately (>50um)") + all_passed = False + + if all_passed: + print("PASS: Square pattern completed successfully") + else: + print("FAIL: Issues detected during square pattern") + + return all_passed + + +def print_diagnostic_info(mc: MotionController): + """Print comprehensive diagnostic information about the controller.""" + print("\n" + "="*70) + print("CONTROLLER DIAGNOSTIC INFO") + print("="*70) + + try: + hw_info = mc.get_hw_info() + print(f"\nHardware Info:") + print(f" Serial Number: {hw_info['serial_number']}") + print(f" Model: {hw_info['model']}") + print(f" Firmware: {hw_info['firmware_version']}") + print(f" HW Version: {hw_info['hw_version']}") + print(f" Channels: {hw_info['num_channels']}") + print(f" Notes: {hw_info['notes']}") + except Exception as e: + print(f" Error getting hardware info: {e}") + + # Request and display velocity params + print(f"\nVelocity Parameters:") + mc.send_command(0x0414, param1=0x01, dest=mc.DEST_X_AXIS) # REQ_VELPARAMS + mc.send_command(0x0414, param1=0x01, dest=mc.DEST_Y_AXIS) + time.sleep(0.1) + + x_vel = mc.velocity_params_x + y_vel = mc.velocity_params_y + if x_vel: + print(f" X-Axis: max_vel={x_vel['max_velocity']:.2f}mm/s, accel={x_vel['acceleration']:.2f}mm/s²") + if y_vel: + print(f" Y-Axis: max_vel={y_vel['max_velocity']:.2f}mm/s, accel={y_vel['acceleration']:.2f}mm/s²") + + +def test_bay_and_channel_status(mc: MotionController) -> dict: + """ + Test bay occupancy and channel enable states. + + Returns dict with diagnostic info about each axis. + """ + print("\n" + "="*70) + print("BAY AND CHANNEL DIAGNOSTICS") + print("="*70) + + results = {'x': {}, 'y': {}} + + # Query bay status using MGMSG_RACK_REQ_BAYUSED (0x0060) + print("\n--- Querying Bay Status (RACK_REQ_BAYUSED 0x0060) ---") + print("Sending to controller (0x11)...") + + # Send bay request to controller + mc.send_command(0x0060, param1=0x00, param2=0x00, dest=0x11, source=0x01) + time.sleep(0.2) + + # Check for response in queue + msgs = mc.get_all_messages() + bay_response = None + for msg in msgs: + print(f" Received: msg_id=0x{msg.msg_id:04X}, source=0x{msg.source:02X}, " + f"param1=0x{msg.param1:02X}, param2=0x{msg.param2:02X}, data={msg.data.hex() if msg.data else 'none'}") + if msg.msg_id == 0x0061: # RACK_GET_BAYUSED + bay_response = msg + + if bay_response: + # param1 contains bay_ident (which bay), param2 contains state + print(f"\nBay status response: bay={bay_response.param1}, state=0x{bay_response.param2:02X}") + else: + print(" No RACK_GET_BAYUSED response received") + + # Check channel enable state for X-axis (0x21) + print("\n--- X-Axis (Bay 1 / dest=0x21) Channel Status ---") + print(f" Destination address: 0x{mc.DEST_X_AXIS:02X}") + + try: + x_enabled = mc.get_channel_enable_state(mc.DEST_X_AXIS, timeout=2.0) + print(f" Channel enabled: {x_enabled}") + results['x']['enabled'] = x_enabled + except Exception as e: + print(f" Error querying channel state: {e}") + results['x']['enabled'] = None + + # Get X position to verify communication + x_pos = mc.get_position(mc.DEST_X_AXIS, timeout=2.0) + print(f" Current position: {x_pos:.4f}mm" if x_pos is not None else " Position query failed!") + results['x']['position'] = x_pos + results['x']['communicating'] = x_pos is not None + + # Check channel enable state for Y-axis (0x22) + print("\n--- Y-Axis (Bay 2 / dest=0x22) Channel Status ---") + print(f" Destination address: 0x{mc.DEST_Y_AXIS:02X}") + + try: + y_enabled = mc.get_channel_enable_state(mc.DEST_Y_AXIS, timeout=2.0) + print(f" Channel enabled: {y_enabled}") + results['y']['enabled'] = y_enabled + except Exception as e: + print(f" Error querying channel state: {e}") + results['y']['enabled'] = None + + # Get Y position to verify communication + y_pos = mc.get_position(mc.DEST_Y_AXIS, timeout=2.0) + print(f" Current position: {y_pos:.4f}mm" if y_pos is not None else " Position query failed!") + results['y']['position'] = y_pos + results['y']['communicating'] = y_pos is not None + + return results + + +def test_y_axis_move_verbose(mc: MotionController) -> bool: + """ + Test Y-axis movement using the new wait_until_settled() method. + + Sends a small Y-axis move and monitors progress. + """ + print("\n" + "="*70) + print("Y-AXIS VERBOSE MOVE TEST") + print("="*70) + print("Using wait_until_settled() for motion detection") + + # Get current Y position + mc.poll_positions() + time.sleep(0.1) + start_y = mc.position_y + + if start_y is None: + print("ERROR: Cannot get Y position") + return False + + print(f"\nCurrent Y position: {start_y:.4f}mm") + + # Target: move 5mm in Y + target_y = start_y + 5.0 + print(f"Target Y position: {target_y:.4f}mm") + + # Issue move command using move_to_fast (which tracks pending moves) + print("\n--- Issuing Y-axis move via move_to_fast() ---") + mc.move_to_fast(y=target_y) + + # Show pending moves + pending = mc.get_pending_targets() + print(f" Pending moves: {pending}") + print(f" is_move_pending(): {mc.is_move_pending()}") + + # Monitor using poll_until_idle + print("\n--- Monitoring Y-axis with poll_until_idle() ---") + print(f"{'Time':>6} {'Y_pos':>10} {'Y_err':>10} {'Pending'}") + print("-" * 45) + + start_time = time.time() + movement_detected = False + + while not mc.poll_until_idle(tolerance=0.005, timeout=0.05): + elapsed = time.time() - start_time + curr_y = mc.position_y + + if curr_y is not None: + y_err = curr_y - target_y + pending = "Y" if mc.is_move_pending() else "-" + print(f"{elapsed:>5.2f}s {curr_y:>10.4f} {y_err:>+10.4f} {pending:>8}") + + if abs(curr_y - start_y) > 0.01: + movement_detected = True + + if elapsed > 10.0: + print("\n** TIMEOUT after 10s **") + mc.clear_pending_moves() + break + + # Final status + elapsed = time.time() - start_time + final_y = mc.position_y + print(f"\n--- Y-Axis Move Summary ---") + print(f"Start position: {start_y:.4f}mm") + print(f"Target position: {target_y:.4f}mm") + print(f"Final position: {final_y:.4f}mm" if final_y else "Final position: unknown") + print(f"Move time: {elapsed:.3f}s") + + if final_y is not None: + distance_moved = abs(final_y - start_y) + print(f"Distance moved: {distance_moved:.4f}mm") + + if distance_moved < 0.01: + print("\n** FAIL: Y-axis did not move at all! **") + print("Possible causes:") + print(" 1. Y-axis channel is disabled") + print(" 2. Y-axis motor driver issue") + print(" 3. Command not reaching Y-axis bay") + print(" 4. Mechanical obstruction") + return False + elif abs(final_y - target_y) < 0.05: + print("\n** PASS: Y-axis moved to target **") + return True + else: + print(f"\n** PARTIAL: Y-axis moved but not to target **") + return False + + return movement_detected + + +def test_enable_and_move_y(mc: MotionController) -> bool: + """ + Explicitly enable Y-axis channel and attempt movement. + """ + print("\n" + "="*70) + print("ENABLE Y-AXIS AND MOVE TEST") + print("="*70) + + # First, explicitly enable Y-axis channel + print("\n--- Enabling Y-axis channel (MOD_SET_CHANENABLESTATE) ---") + print(f" Sending to dest=0x{mc.DEST_Y_AXIS:02X}, enable=True") + + try: + mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True) + time.sleep(0.2) + print(" Enable command sent") + except Exception as e: + print(f" Error sending enable: {e}") + + # Verify it's enabled + try: + y_enabled = mc.get_channel_enable_state(mc.DEST_Y_AXIS, timeout=2.0) + print(f" Channel enabled state: {y_enabled}") + except Exception as e: + print(f" Error checking state: {e}") + + # Request fresh status + mc.request_status_update(mc.DEST_Y_AXIS) + time.sleep(0.2) + + y_bits = mc.status_bits_y + print(f" Status bits: {format_status_bits(y_bits)}") + + # Now try the verbose move test + return test_y_axis_move_verbose(mc) + + +def main(): + print("="*70) + print("BBD202 Stage Diagnostic Test") + print("="*70) + + mc = None + try: + # Connect to controller + print("\nConnecting to BBD202 controller...") + mc = MotionController() + mc.connect(enable_updates=True) + print("Connected!") + + # Print diagnostic info + print_diagnostic_info(mc) + + # Run bay and channel diagnostics + channel_results = test_bay_and_channel_status(mc) + + # Run homing status test + homed = test_homing_status(mc) + + if not homed: + print("\n" + "-"*70) + response = input("Axes not homed. Home now? (y/n): ").strip().lower() + if response == 'y': + print("\nHoming X-axis...") + x_result = mc.home_x_axis(timeout=30.0) + print(f"X-axis homing: {'SUCCESS' if x_result else 'FAILED'}") + + print("\nHoming Y-axis...") + y_result = mc.home_y_axis(timeout=30.0) + print(f"Y-axis homing: {'SUCCESS' if y_result else 'FAILED'}") + + if not (x_result and y_result): + print("\nHoming failed. Cannot continue with movement test.") + return 1 + + # Re-check homing status + test_homing_status(mc) + else: + print("\nSkipping movement tests (axes not homed)") + return 1 + + # Run Y-axis verbose test first to diagnose the issue + y_axis_ok = test_enable_and_move_y(mc) + + # If Y-axis failed, skip the square pattern + if not y_axis_ok: + print("\n** Y-axis movement failed - skipping square pattern test **") + square_ok = False + else: + # Run square pattern test + square_ok = test_square_pattern(mc, side_length=15.0) + + # Final summary + print("\n" + "="*70) + print("DIAGNOSTIC SUMMARY") + print("="*70) + print(f"X-Axis Channel: {'OK' if channel_results['x'].get('communicating') else 'FAIL'}") + print(f"Y-Axis Channel: {'OK' if channel_results['y'].get('communicating') else 'FAIL'}") + print(f"Homing Status: {'PASS' if homed else 'FAIL'}") + print(f"Y-Axis Move: {'PASS' if y_axis_ok else 'FAIL'}") + print(f"Square Pattern: {'PASS' if square_ok else 'FAIL/SKIPPED'}") + + if homed and y_axis_ok and square_ok: + print("\nAll tests PASSED") + return 0 + else: + print("\nSome tests FAILED - see details above") + return 1 + + except KeyboardInterrupt: + print("\n\nTest interrupted by user") + return 1 + except Exception as e: + print(f"\nError during test: {e}") + import traceback + traceback.print_exc() + return 1 + finally: + if mc is not None: + print("\nDisconnecting from controller...") + mc.disconnect() + print("Disconnected") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_bbd202_snake.py b/tests/test_bbd202_snake.py new file mode 100644 index 0000000..1b1e4ee --- /dev/null +++ b/tests/test_bbd202_snake.py @@ -0,0 +1,266 @@ +""" +BBD202 Snake Test + +Quick test to verify the BBD202 stage driver with a snake scan pattern. +Scans a 20mm x 20mm area with 2mm row spacing in a back-and-forth pattern. +""" + +import sys +import time +from hardware.bbd202 import MotionController, MotorStatusBits + + +def snake_test(mc: MotionController, width: float = 20.0, height: float = 20.0, row_spacing: float = 2.0) -> bool: + """ + Execute a snake scan pattern. + + Args: + mc: MotionController instance + width: Scan width in mm (X direction) + height: Scan height in mm (Y direction) + row_spacing: Distance between rows in mm + + Pattern: + Start (0,0) -> Move +X to (width, 0) -> Move +Y by row_spacing -> + Move -X to (0, row_spacing) -> Move +Y by row_spacing -> + ... repeat until height is covered + """ + print("\n" + "="*70) + print(f"SNAKE TEST: {width}mm x {height}mm area, {row_spacing}mm row spacing") + print("="*70) + + # Get starting position + mc.poll_positions() + time.sleep(0.1) + start_x = mc.position_x + start_y = mc.position_y + + if start_x is None or start_y is None: + print("ERROR: Could not get initial position") + return False + + print(f"\nStarting position: X={start_x:.4f}mm, Y={start_y:.4f}mm") + + # Calculate number of rows + num_rows = int(height / row_spacing) + 1 + print(f"Number of rows: {num_rows}") + + # Generate snake path waypoints + waypoints = [] + for row in range(num_rows): + y_pos = start_y + row * row_spacing + + if row % 2 == 0: + # Even row: move left to right + waypoints.append((start_x + width, y_pos, f"Row {row+1}/{num_rows} (+X)")) + else: + # Odd row: move right to left + waypoints.append((start_x, y_pos, f"Row {row+1}/{num_rows} (-X)")) + + print(f"Total waypoints: {len(waypoints)}") + + all_passed = True + total_distance = 0.0 + total_time = 0.0 + + # Execute the snake pattern + for i, (target_x, target_y, description) in enumerate(waypoints): + print(f"\n--- Move {i+1}/{len(waypoints)}: {description} ---") + print(f"Target: X={target_x:.4f}mm, Y={target_y:.4f}mm") + + # Record start time and position + move_start = time.time() + mc.poll_positions() + time.sleep(0.02) + curr_x = mc.position_x + curr_y = mc.position_y + + if curr_x is not None and curr_y is not None: + distance = ((target_x - curr_x)**2 + (target_y - curr_y)**2)**0.5 + total_distance += distance + + # Clear any previous error + mc.clear_last_error() + + # Issue move command + mc.move_to_fast(x=target_x, y=target_y) + + # Wait for move to complete with periodic progress updates + sample_count = 0 + max_err = 0.0 + + while not mc.poll_until_idle(tolerance=0.005, timeout=0.05): + elapsed = time.time() - move_start + curr_x = mc.position_x + curr_y = mc.position_y + + if curr_x is not None and curr_y is not None: + x_err = curr_x - target_x + y_err = curr_y - target_y + total_err = (x_err**2 + y_err**2)**0.5 + max_err = max(max_err, total_err) + + # Print every 10th sample + if sample_count % 10 == 0: + print(f" {elapsed:>5.2f}s X={curr_x:>8.4f} Y={curr_y:>8.4f} err={total_err*1000:>6.1f}um", end='\r') + + sample_count += 1 + + # Timeout check + if elapsed > 30.0: + print(f"\n** TIMEOUT after 30s - stage may be hung!") + all_passed = False + mc.clear_pending_moves() + break + + # Move complete + elapsed = time.time() - move_start + total_time += elapsed + + mc.poll_positions() + time.sleep(0.02) + final_x = mc.position_x + final_y = mc.position_y + + if final_x is not None and final_y is not None: + x_err = final_x - target_x + y_err = final_y - target_y + total_err = (x_err**2 + y_err**2)**0.5 + print(f" {elapsed:>5.2f}s X={final_x:>8.4f} Y={final_y:>8.4f} err={total_err*1000:>6.1f}um ** DONE **") + + if total_err > 0.05: # 50um tolerance + print(f" ** WARNING: Position error exceeds 50um: {total_err*1000:.1f}um") + all_passed = False + + # Check for errors after move + err = mc.check_for_errors() + if err: + print(f" ** Error after move: {err}") + all_passed = False + + last_err = mc.last_error + if last_err: + print(f" ** Last error: {last_err}") + all_passed = False + + # Return to start position + print(f"\n--- Returning to start position ---") + move_start = time.time() + mc.move_to_fast(x=start_x, y=start_y) + + while not mc.poll_until_idle(tolerance=0.005, timeout=0.05): + if time.time() - move_start > 30.0: + print("** TIMEOUT returning to start") + mc.clear_pending_moves() + break + + elapsed = time.time() - move_start + total_time += elapsed + + # Final summary + print(f"\n{'='*70}") + print("SNAKE TEST SUMMARY") + print(f"{'='*70}") + + mc.poll_positions() + time.sleep(0.1) + final_x = mc.position_x + final_y = mc.position_y + + if final_x is not None and final_y is not None: + return_x_err = abs(final_x - start_x) + return_y_err = abs(final_y - start_y) + return_err = (return_x_err**2 + return_y_err**2)**0.5 + + print(f"Start position: X={start_x:.4f}mm, Y={start_y:.4f}mm") + print(f"Final position: X={final_x:.4f}mm, Y={final_y:.4f}mm") + print(f"Return error: {return_err*1000:.1f}um (X={return_x_err*1000:.1f}um, Y={return_y_err*1000:.1f}um)") + print(f"Total distance: {total_distance:.2f}mm") + print(f"Total time: {total_time:.2f}s") + print(f"Average speed: {total_distance/total_time:.2f}mm/s") + print(f"Waypoints: {len(waypoints)}") + + if return_err > 0.05: # 50um tolerance + print(f"\n** FAIL: Did not return to start position (error: {return_err*1000:.1f}um > 50um)") + all_passed = False + + if all_passed: + print(f"\n** PASS: Snake test completed successfully **") + else: + print(f"\n** FAIL: Issues detected during snake test **") + + return all_passed + + +def main(): + print("="*70) + print("BBD202 Snake Test") + print("="*70) + + mc = None + try: + # Connect to controller + print("\nConnecting to BBD202 controller...") + mc = MotionController() + mc.connect(enable_updates=True) + print("Connected!") + + # Brief hardware info + try: + hw_info = mc.get_hw_info() + print(f"\nHardware: {hw_info['model']} (S/N: {hw_info['serial_number']})") + print(f"Firmware: {hw_info['firmware_version']}") + except Exception as e: + print(f"Could not get hardware info: {e}") + + # Check homing status + print("\n--- Checking Homing Status ---") + mc.request_status_update(mc.DEST_X_AXIS) + mc.request_status_update(mc.DEST_Y_AXIS) + time.sleep(0.2) + mc.poll_status() + time.sleep(0.1) + + x_homed = mc.is_homed_x + y_homed = mc.is_homed_y + + print(f"X-axis homed: {x_homed}") + print(f"Y-axis homed: {y_homed}") + + if not (x_homed and y_homed): + print("\n** Axes not homed - will home now **") + + print("\nHoming X-axis...") + x_result = mc.home_x_axis(timeout=30.0) + print(f"X-axis: {'SUCCESS' if x_result else 'FAILED'}") + + print("\nHoming Y-axis...") + y_result = mc.home_y_axis(timeout=30.0) + print(f"Y-axis: {'SUCCESS' if y_result else 'FAILED'}") + + if not (x_result and y_result): + print("\n** Homing failed. Cannot continue. **") + return 1 + + # Run the snake test + success = snake_test(mc, width=20.0, height=20.0, row_spacing=2.0) + + return 0 if success else 1 + + except KeyboardInterrupt: + print("\n\nTest interrupted by user") + return 1 + except Exception as e: + print(f"\n** ERROR: {e}") + import traceback + traceback.print_exc() + return 1 + finally: + if mc is not None: + print("\nDisconnecting from controller...") + mc.disconnect() + print("Disconnected") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_bbd202_snake_20x20.py b/tests/test_bbd202_snake_20x20.py new file mode 100644 index 0000000..2ace11a --- /dev/null +++ b/tests/test_bbd202_snake_20x20.py @@ -0,0 +1,300 @@ +""" +BBD202 Snake Scan Test - 20x20mm Area + +Scans a 20mm x 20mm area centered at X=55, Y=37.5 in a snake pattern. +Row spacing: 0.5mm +Movement order: X+, Y+, X-, Y+ (snake pattern) +""" + +import sys +import time +import argparse +from hardware.bbd202 import MotionController, MotorStatusBits + + +# Scan parameters +CENTER_X = 55.0 # mm +CENTER_Y = 37.5 # mm +SCAN_WIDTH = 20.0 # mm (X direction) +SCAN_HEIGHT = 20.0 # mm (Y direction) +ROW_SPACING = 0.5 # mm (Y step between rows) + +# Calculated bounds +START_X = CENTER_X - SCAN_WIDTH / 2 # 45.0 mm +END_X = CENTER_X + SCAN_WIDTH / 2 # 65.0 mm +START_Y = CENTER_Y - SCAN_HEIGHT / 2 # 27.5 mm +END_Y = CENTER_Y + SCAN_HEIGHT / 2 # 47.5 mm + + +def snake_scan(mc: MotionController) -> bool: + """ + Execute a snake scan pattern over the 20x20mm area. + + Pattern (X+, Y+, X-, Y+): + Row 0: X=45 -> X=65 (X+) + Step: Y += 0.5 (Y+) + Row 1: X=65 -> X=45 (X-) + Step: Y += 0.5 (Y+) + ... repeat until Y reaches 47.5mm + """ + print("\n" + "=" * 70) + print("SNAKE SCAN TEST: 20mm x 20mm") + print("=" * 70) + print(f"Center: X={CENTER_X:.1f}mm, Y={CENTER_Y:.1f}mm") + print(f"Bounds: X=[{START_X:.1f}, {END_X:.1f}]mm, Y=[{START_Y:.1f}, {END_Y:.1f}]mm") + print(f"Row spacing: {ROW_SPACING}mm") + print(f"Pattern: X+, Y+, X-, Y+ (snake)") + + # Calculate number of rows + num_rows = int(SCAN_HEIGHT / ROW_SPACING) + 1 + print(f"Total rows: {num_rows}") + + # Stop automatic ACK to prevent interference with move commands + # We'll send manual ACKs after each move completes + mc.stop_status_ack() + print("Using manual ACK mode for scan") + + # First, move to the starting position + print(f"\n--- Moving to start position ({START_X:.1f}, {START_Y:.1f}) ---") + mc.move_to_fast(x=START_X, y=START_Y) + + move_start = time.time() + while not mc.poll_until_idle(tolerance=0.005, timeout=0.05): + if time.time() - move_start > 30.0: + print("** TIMEOUT moving to start position!") + return False + + # Verify starting position + mc.poll_positions() + time.sleep(0.1) + curr_x = mc.position_x + curr_y = mc.position_y + print(f"At start: X={curr_x:.4f}mm, Y={curr_y:.4f}mm") + + # Generate snake path waypoints + waypoints = [] + for row in range(num_rows): + y_pos = START_Y + row * ROW_SPACING + + if row % 2 == 0: + # Even row: move X+ (left to right) + waypoints.append((END_X, y_pos, f"Row {row + 1}/{num_rows} X+ (Y={y_pos:.1f})")) + else: + # Odd row: move X- (right to left) + waypoints.append((START_X, y_pos, f"Row {row + 1}/{num_rows} X- (Y={y_pos:.1f})")) + + print(f"\nTotal waypoints: {len(waypoints)}") + print("-" * 70) + + all_passed = True + total_distance = 0.0 + total_time = 0.0 + scan_start_time = time.time() + + # Execute the snake pattern + for i, (target_x, target_y, description) in enumerate(waypoints): + # Record start position for distance calculation + mc.poll_positions() + time.sleep(0.02) + prev_x = mc.position_x + prev_y = mc.position_y + + if prev_x is not None and prev_y is not None: + distance = ((target_x - prev_x) ** 2 + (target_y - prev_y) ** 2) ** 0.5 + total_distance += distance + + # Clear any previous error + mc.clear_last_error() + + # Issue move command + move_start = time.time() + mc.move_to_fast(x=target_x, y=target_y) + + # Wait for move to complete + while not mc.poll_until_idle(tolerance=0.005, timeout=0.05): + elapsed = time.time() - move_start + + # Timeout check + if elapsed > 30.0: + print(f"\n** TIMEOUT on {description} after 30s!") + all_passed = False + mc.clear_pending_moves() + break + + # Move complete - send ACK to keep controller responsive + mc.ack_status_update() + + elapsed = time.time() - move_start + total_time += elapsed + + mc.poll_positions() + time.sleep(0.02) + final_x = mc.position_x + final_y = mc.position_y + + if final_x is not None and final_y is not None: + x_err = final_x - target_x + y_err = final_y - target_y + total_err = (x_err ** 2 + y_err ** 2) ** 0.5 + + # Print progress (compact format) + status = "OK" if total_err < 0.05 else "ERR" + print(f" {description:35} -> X={final_x:7.3f} Y={final_y:7.3f} err={total_err * 1000:5.1f}um [{status}]") + + if total_err > 0.05: # 50um tolerance + all_passed = False + + # Check for errors after move + err = mc.check_for_errors() + if err: + print(f" ** Error: {err}") + all_passed = False + + last_err = mc.last_error + if last_err: + print(f" ** Last error: {last_err}") + all_passed = False + + scan_elapsed = time.time() - scan_start_time + + # Return to start position + print(f"\n--- Returning to start position ({START_X:.1f}, {START_Y:.1f}) ---") + move_start = time.time() + mc.move_to_fast(x=START_X, y=START_Y) + + while not mc.poll_until_idle(tolerance=0.005, timeout=0.05): + if time.time() - move_start > 30.0: + print("** TIMEOUT returning to start") + mc.clear_pending_moves() + break + + # Final summary + print(f"\n{'=' * 70}") + print("SNAKE SCAN SUMMARY") + print(f"{'=' * 70}") + + mc.poll_positions() + time.sleep(0.1) + final_x = mc.position_x + final_y = mc.position_y + + if final_x is not None and final_y is not None: + return_x_err = abs(final_x - START_X) + return_y_err = abs(final_y - START_Y) + return_err = (return_x_err ** 2 + return_y_err ** 2) ** 0.5 + + print(f"Scan area: {SCAN_WIDTH:.1f}mm x {SCAN_HEIGHT:.1f}mm") + print(f"Center: X={CENTER_X:.1f}mm, Y={CENTER_Y:.1f}mm") + print(f"Row spacing: {ROW_SPACING}mm") + print(f"Rows completed: {num_rows}") + print(f"Total distance: {total_distance:.2f}mm") + print(f"Scan time: {scan_elapsed:.2f}s") + print(f"Average speed: {total_distance / scan_elapsed:.2f}mm/s") + print(f"Return error: {return_err * 1000:.1f}um (X={return_x_err * 1000:.1f}um, Y={return_y_err * 1000:.1f}um)") + + if return_err > 0.05: + print(f"\n** FAIL: Return position error exceeds 50um") + all_passed = False + + # Restart automatic ACK + mc.start_status_ack() + + if all_passed: + print(f"\n** PASS: Snake scan completed successfully **") + else: + print(f"\n** FAIL: Issues detected during snake scan **") + + return all_passed + + +def main(): + parser = argparse.ArgumentParser(description="BBD202 Snake Scan Test - 20x20mm Area") + parser.add_argument("--auto-home", action="store_true", + help="Automatically home axes if not homed (no prompt)") + args = parser.parse_args() + + print("=" * 70) + print("BBD202 Snake Scan Test - 20x20mm Area") + print("=" * 70) + + mc = None + try: + # Connect to controller + print("\nConnecting to BBD202 controller...") + mc = MotionController() + mc.connect(enable_updates=True) + print("Connected!") + + # Brief hardware info + try: + hw_info = mc.get_hw_info() + print(f"\nHardware: {hw_info['model']} (S/N: {hw_info['serial_number']})") + print(f"Firmware: {hw_info['firmware_version']}") + except Exception as e: + print(f"Could not get hardware info: {e}") + + # Check homing status + print("\n--- Checking Homing Status ---") + mc.request_status_update(mc.DEST_X_AXIS) + mc.request_status_update(mc.DEST_Y_AXIS) + time.sleep(0.2) + mc.poll_status() + time.sleep(0.1) + + x_homed = mc.is_homed_x + y_homed = mc.is_homed_y + + print(f"X-axis homed: {x_homed}") + print(f"Y-axis homed: {y_homed}") + + if not (x_homed and y_homed): + print("\n** Axes not homed! **") + + # Check if we should auto-home or prompt + should_home = args.auto_home + if not should_home: + try: + response = input("Home axes now? (y/n): ").strip().lower() + should_home = response == 'y' + except EOFError: + print("Non-interactive mode detected. Use --auto-home flag.") + return 1 + + if should_home: + print("\nHoming X-axis...") + x_result = mc.home_x_axis(timeout=30.0) + print(f"X-axis: {'SUCCESS' if x_result else 'FAILED'}") + + print("\nHoming Y-axis...") + y_result = mc.home_y_axis(timeout=30.0) + print(f"Y-axis: {'SUCCESS' if y_result else 'FAILED'}") + + if not (x_result and y_result): + print("\n** Homing failed. Cannot continue. **") + return 1 + else: + print("\nCannot run snake scan without homing. Exiting.") + return 1 + + # Run the snake scan + success = snake_scan(mc) + + return 0 if success else 1 + + except KeyboardInterrupt: + print("\n\nTest interrupted by user") + return 1 + except Exception as e: + print(f"\n** ERROR: {e}") + import traceback + traceback.print_exc() + return 1 + finally: + if mc is not None: + print("\nDisconnecting from controller...") + mc.disconnect() + print("Disconnected") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_camera_integration.py b/tests/test_camera_integration.py new file mode 100644 index 0000000..116e01c --- /dev/null +++ b/tests/test_camera_integration.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +Test script for camera integration with the scan wizard. +Tests the camera driver and integration with the UI. +""" + +import sys +from PyQt6 import QtWidgets + +def test_camera_import(): + """Test that the camera driver can be imported""" + print("Testing camera driver import...") + try: + from uc480_camera import UC480Camera, CameraStreamThread + print("✓ Camera driver imported successfully") + return True + except ImportError as e: + print(f"✗ Failed to import camera driver: {e}") + return False + +def test_camera_class(): + """Test that the camera class can be instantiated""" + print("\nTesting camera class instantiation...") + try: + from uc480_camera import UC480Camera + camera = UC480Camera(camera_id=0) + print("✓ Camera class instantiated successfully") + print(f" Camera handle: {camera.h_cam}") + print(f" Initialized: {camera.is_initialized}") + return True + except Exception as e: + print(f"✗ Failed to instantiate camera class: {e}") + return False + +def test_scanengine_import(): + """Test that the scanengine app can be imported with camera integration""" + print("\nTesting scanengine app import...") + try: + from scanengine_app import NewScanWizard, MainLauncher + print("✓ Scanengine app imported successfully") + return True + except ImportError as e: + print(f"✗ Failed to import scanengine app: {e}") + return False + +def test_wizard_with_camera(): + """Test that the wizard can be created with camera integration""" + print("\nTesting wizard with camera integration...") + try: + app = QtWidgets.QApplication(sys.argv) + from scanengine_app import NewScanWizard + + wizard = NewScanWizard() + print("✓ Wizard created successfully") + print(f" Camera object: {wizard.camera}") + print(f" Camera stream thread: {wizard.camera_stream_thread}") + print(f" CCD scene: {wizard.ccd_scene}") + + # Check if camera methods exist + assert hasattr(wizard, 'initialize_camera'), "Missing initialize_camera method" + assert hasattr(wizard, 'start_camera_stream'), "Missing start_camera_stream method" + assert hasattr(wizard, 'stop_camera_stream'), "Missing stop_camera_stream method" + assert hasattr(wizard, 'cleanup_camera'), "Missing cleanup_camera method" + print("✓ All camera methods present") + + # Test page change triggers + print("\nTesting page change behavior...") + current_page = wizard.stackedWidget.currentIndex() + print(f" Current page: {current_page}") + + # Simulate page navigation to focus page (index 1) + print(" Navigating to focus page (index 1)...") + wizard.stackedWidget.setCurrentIndex(1) + print(f" Current page after navigation: {wizard.stackedWidget.currentIndex()}") + + # Navigate back to first page + print(" Navigating back to page 0...") + wizard.stackedWidget.setCurrentIndex(0) + print(f" Current page after navigation: {wizard.stackedWidget.currentIndex()}") + + # Cleanup + wizard.cleanup_camera() + print("✓ Camera cleanup successful") + + return True + except Exception as e: + print(f"✗ Failed to test wizard with camera: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + """Run all tests""" + print("=" * 60) + print("Camera Integration Test Suite") + print("=" * 60) + + results = [] + + # Run tests + results.append(("Camera Import", test_camera_import())) + results.append(("Camera Class", test_camera_class())) + results.append(("Scanengine Import", test_scanengine_import())) + results.append(("Wizard Integration", test_wizard_with_camera())) + + # Print summary + print("\n" + "=" * 60) + print("Test Summary") + print("=" * 60) + for test_name, passed in results: + status = "PASS" if passed else "FAIL" + symbol = "✓" if passed else "✗" + print(f"{symbol} {test_name}: {status}") + + all_passed = all(result[1] for result in results) + print("\n" + "=" * 60) + if all_passed: + print("All tests passed!") + else: + print("Some tests failed.") + print("=" * 60) + + return 0 if all_passed else 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_genesis_connection.py b/tests/test_genesis_connection.py new file mode 100755 index 0000000..1b0bd04 --- /dev/null +++ b/tests/test_genesis_connection.py @@ -0,0 +1,196 @@ +#!/opt/srasenv/bin/python3 +""" +Test script for Genesis laser serial communication. +Tests basic connectivity and I2C protocol without GUI. +""" + +import sys +import time +import serial + +# Constants +NXP_START_BYTE = 0x53 +NXP_STOP_BYTE = 0x50 +ADDR_PCA9555_PS_GLUE_OUT = 0x4a +ADDR_ADS7828 = 0x90 +CHAN_CURRENT_ACTUAL = 0x84 + +def test_serial_connection(port_name="/dev/ttyUSB1", baudrate=9600): + """Test basic serial connection.""" + print(f"Testing serial connection to {port_name} @ {baudrate}...") + try: + port = serial.Serial( + port=port_name, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1.0 + ) + print("✓ Serial port opened successfully") + return port + except Exception as e: + print(f"✗ Failed to open serial port: {e}") + return None + +def nxp_write(port, i2c_addr_write, cmd, data, data_len): + """Build and send NXP I2C write packet.""" + # Determine command length + if cmd <= 0xFF: + cmd_bytes = bytes([cmd]) + else: + cmd_bytes = cmd.to_bytes(2, 'big') + + # Convert data to bytes (big-endian) + data_bytes = data.to_bytes(data_len, 'big') + + # Calculate total length + total_len = len(cmd_bytes) + len(data_bytes) + + # Build packet: [0x53][addr][len][cmd...][data...][0x50] + packet = bytes([ + NXP_START_BYTE, + i2c_addr_write, + total_len + ]) + cmd_bytes + data_bytes + bytes([NXP_STOP_BYTE]) + + print(f" TX: {packet.hex(' ')}") + port.write(packet) + return True + +def nxp_read(port, i2c_addr_write, cmd, cmd_len, data_len): + """Build and send NXP I2C read packet.""" + # Convert command to bytes + if cmd_len == 1: + cmd_bytes = bytes([cmd]) + else: + cmd_bytes = cmd.to_bytes(2, 'big') + + # Build packet: [0x53][addr][cmd_len][cmd...][0x53][addr|0x01][data_len][0x50] + packet = bytes([ + NXP_START_BYTE, + i2c_addr_write, + cmd_len + ]) + cmd_bytes + bytes([ + NXP_START_BYTE, + i2c_addr_write | 0x01, # Read address + data_len, + NXP_STOP_BYTE + ]) + + print(f" TX: {packet.hex(' ')}") + port.write(packet) + time.sleep(0.1) + + # Read response + response = port.read(data_len) + if response: + print(f" RX: {response.hex(' ')} ({len(response)} bytes)") + return int.from_bytes(response, 'big') + else: + print(f" RX: (no data)") + return None + +def test_read_ps_glue_out(port): + """Test reading PS glue output port.""" + print("\nTest 1: Reading PS glue output port (0x4a, reg 0x02)...") + value = nxp_read(port, ADDR_PCA9555_PS_GLUE_OUT, 0x02, 1, 1) + if value is not None: + print(f"✓ PS Glue Out status: 0x{value:02x}") + print(f" Shutter: {'OPEN' if value & 0x01 else 'CLOSED'}") + print(f" Current Mode: {'ON' if value & 0x04 else 'OFF'}") + print(f" Remote Enable: {'ON' if value & 0x08 else 'OFF'}") + print(f" Analog Enable: {'ON' if value & 0x10 else 'OFF'}") + print(f" Keyswitch: {'ON' if value & 0x20 else 'OFF'}") + return True + else: + print("✗ Failed to read PS glue out") + return False + +def test_read_current_adc(port): + """Test reading current ADC.""" + print("\nTest 2: Reading current ADC (ADS7828, channel 0x84)...") + value = nxp_read(port, ADDR_ADS7828, CHAN_CURRENT_ACTUAL, 1, 2) + if value is not None: + print(f"✓ Current ADC raw value: 0x{value:04x} ({value})") + scaled = value * 0.000244140625 + print(f" Scaled value: {scaled:.6f}") + return True + else: + print("✗ Failed to read current ADC") + return False + +def test_write_current_zero(port): + """Test setting current to zero.""" + print("\nTest 3: Setting current to 0 (X9119 @ 0x52, cmd 0xa0)...") + success = nxp_write(port, 0x52, 0xa0, 0, 2) + if success: + print("✓ Current set to 0 command sent") + return True + else: + print("✗ Failed to set current") + return False + +def test_read_status_bits(port): + """Test reading all status bits from PS glue output.""" + print("\nTest 4: Reading all control status bits...") + # Read current state + current_state = nxp_read(port, ADDR_PCA9555_PS_GLUE_OUT, 0x02, 1, 1) + if current_state is None: + print("✗ Failed to read current state") + return False + + print(f" Current state: 0x{current_state:02x}") + print(f" Note: Shutter bit status: {'SET' if current_state & 0x01 else 'CLEAR'} (manual shutter - not controlled)") + print("✓ Successfully read all status bits") + return True + +def main(): + """Run all tests.""" + print("=" * 60) + print("Genesis SLM MX 532 - Serial Communication Test") + print("=" * 60) + + # Open serial port + port = test_serial_connection() + if not port: + print("\nTest FAILED: Cannot open serial port") + return 1 + + try: + # Run tests + tests_passed = 0 + tests_total = 4 + + if test_read_ps_glue_out(port): + tests_passed += 1 + + if test_read_current_adc(port): + tests_passed += 1 + + if test_write_current_zero(port): + tests_passed += 1 + + if test_read_status_bits(port): + tests_passed += 1 + + # Summary + print("\n" + "=" * 60) + print(f"Test Summary: {tests_passed}/{tests_total} tests passed") + print("=" * 60) + + if tests_passed == tests_total: + print("✓ All tests PASSED - Communication working!") + print("\nYou can now run the GUI application:") + print(" ./genesis_laser_gui.py") + return 0 + else: + print("✗ Some tests FAILED - Check connections") + return 1 + + finally: + port.close() + print("\nSerial port closed.") + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_genesis_protocol.py b/tests/test_genesis_protocol.py new file mode 100755 index 0000000..66e8d31 --- /dev/null +++ b/tests/test_genesis_protocol.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +Genesis Laser Protocol Test Script +=================================== + +Simple command-line script to test NXP I2C-over-serial communication +with the Genesis SLM MX 532 laser. + +Usage: + python test_genesis_protocol.py [port] [baudrate] + +Example: + python test_genesis_protocol.py /dev/ttyUSB0 9600 +""" + +import sys +import time +import serial +from typing import Optional + + +class NXPProtocolTester: + """Simple NXP I2C protocol tester""" + + NXP_START = 0x53 + NXP_STOP = 0x50 + + def __init__(self, port: str = "/dev/ttyUSB0", baudrate: int = 9600): + self.port = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1.0 + ) + time.sleep(0.1) + print(f"Connected to {port} at {baudrate} baud") + + def send_packet(self, i2c_addr: int, cmd: bytes, data: bytes = b''): + """Send NXP I2C write packet""" + if not isinstance(cmd, bytes): + cmd = bytes([cmd]) + if not isinstance(data, bytes): + data = bytes(data) + + length = len(cmd) + len(data) + packet = bytes([self.NXP_START, i2c_addr, length]) + cmd + data + bytes([self.NXP_STOP]) + + print(f"TX: {' '.join(f'{b:02x}' for b in packet)}") + self.port.write(packet) + + def read_packet(self, i2c_addr_write: int, cmd: bytes, cmd_len: int, data_len: int) -> Optional[bytes]: + """Send NXP I2C read packet and return data""" + if not isinstance(cmd, bytes): + cmd = bytes([cmd]) + + i2c_addr_read = i2c_addr_write | 0x01 + + packet_write = bytes([self.NXP_START, i2c_addr_write, cmd_len]) + cmd + packet_read = bytes([self.NXP_START, i2c_addr_read, data_len, self.NXP_STOP]) + packet = packet_write + packet_read + + print(f"TX: {' '.join(f'{b:02x}' for b in packet)}") + self.port.write(packet) + + time.sleep(0.05) + response = self.port.read(data_len) + + if response: + print(f"RX: {' '.join(f'{b:02x}' for b in response)}") + return response + else: + print("RX: (no response)") + return None + + def test_x9119_current(self, value: int = 100): + """Test X9119 current control""" + print(f"\n=== Testing X9119 Current Control (value={value}) ===") + + # X9119 current control at 0x52 + # Command 0xa0, 2 bytes data (10-bit value) + value = max(0, min(1023, value)) + msb = (value >> 8) & 0x03 + lsb = value & 0xFF + + self.send_packet(0x52, bytes([0xa0]), bytes([msb, lsb])) + print("Current command sent") + + def test_pca9555_shutter(self, state: bool): + """Test PCA9555 shutter control""" + print(f"\n=== Testing PCA9555 Shutter Control (state={'OPEN' if state else 'CLOSED'}) ===") + + # Read current output port 0 value + print("Reading current port value...") + current = self.read_packet(0x4a, bytes([0x02]), 1, 1) + + if current and len(current) == 1: + current_value = current[0] + print(f"Current port value: 0x{current_value:02x}") + + # Modify bit 0 (shutter) + if state: + new_value = current_value | 0x01 + else: + new_value = current_value & ~0x01 + + print(f"New port value: 0x{new_value:02x}") + + # Write back + self.send_packet(0x4a, bytes([0x02]), bytes([new_value])) + print("Shutter command sent") + else: + print("Failed to read current port value") + + def test_ads7828_current_reading(self): + """Test ADS7828 current reading""" + print(f"\n=== Testing ADS7828 Current Reading ===") + + # ADS7828 at 0x90/0x91 + # Command byte: 0x80 (single-ended) | (channel << 4) | 0x0c (internal ref) + # Reading channel 0 + cmd_byte = 0x80 | (0 << 4) | 0x0c + + data = self.read_packet(0x90, bytes([cmd_byte]), 1, 2) + + if data and len(data) == 2: + value = (data[0] << 8) | data[1] + value = (value >> 4) & 0x0FFF + print(f"Current reading: {value} counts (0x{value:03x})") + else: + print("Failed to read current") + + def test_pca9555_read_ports(self): + """Test reading all PCA9555 I/O expander ports""" + print(f"\n=== Testing PCA9555 Port Reads ===") + + devices = [ + (0x4a, "Main DIO (0x14a)"), + (0x48, "PS Glue (0x148)"), + (0x44, "Head DIO (0x144)"), + (0x40, "LDD Control (0x140)"), + ] + + for addr, name in devices: + print(f"\n{name}:") + for port in range(8): + data = self.read_packet(addr, bytes([port]), 1, 1) + if data and len(data) == 1: + print(f" Register 0x{port:02x}: 0x{data[0]:02x} (0b{data[0]:08b})") + else: + print(f" Register 0x{port:02x}: read failed") + time.sleep(0.05) + + def close(self): + """Close serial port""" + self.port.close() + print("\nConnection closed") + + +def main(): + """Main test function""" + # Parse command line arguments + port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB0" + baudrate = int(sys.argv[2]) if len(sys.argv) > 2 else 9600 + + try: + # Create tester + tester = NXPProtocolTester(port, baudrate) + + # Menu + while True: + print("\n" + "="*60) + print("Genesis Laser Protocol Test Menu") + print("="*60) + print("1. Test X9119 current control (set to 100)") + print("2. Test PCA9555 shutter OPEN") + print("3. Test PCA9555 shutter CLOSE") + print("4. Test ADS7828 current reading") + print("5. Test read all PCA9555 ports") + print("6. Set current to 0 (safe state)") + print("7. Emergency stop (shutter close + current 0)") + print("8. Custom X9119 current value") + print("0. Exit") + print("="*60) + + choice = input("Enter choice: ").strip() + + if choice == "1": + tester.test_x9119_current(100) + + elif choice == "2": + tester.test_pca9555_shutter(True) + + elif choice == "3": + tester.test_pca9555_shutter(False) + + elif choice == "4": + tester.test_ads7828_current_reading() + + elif choice == "5": + tester.test_pca9555_read_ports() + + elif choice == "6": + print("\n=== Setting current to 0 ===") + tester.test_x9119_current(0) + + elif choice == "7": + print("\n=== EMERGENCY STOP ===") + tester.test_pca9555_shutter(False) + time.sleep(0.1) + tester.test_x9119_current(0) + print("Emergency stop complete") + + elif choice == "8": + try: + value = int(input("Enter current value (0-1023): ")) + tester.test_x9119_current(value) + except ValueError: + print("Invalid value") + + elif choice == "0": + break + + else: + print("Invalid choice") + + tester.close() + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/tests/test_rotated_aoi.py b/tests/test_rotated_aoi.py new file mode 100644 index 0000000..d655e0c --- /dev/null +++ b/tests/test_rotated_aoi.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +Test script for the new rotated AoI bounding box approach. +""" + +from decimal import Decimal +from scanning.sc3_scan_model import SC3ScanModel + +def test_rotated_aoi(): + """Test the rotated AoI bounding box implementation.""" + + # Create scan model + model = SC3ScanModel() + + # Configure scan parameters + model.x_origin = Decimal('50.0') + model.y_origin = Decimal('35.0') + model.x_delta = Decimal('10.0') + model.y_delta = Decimal('5.0') + model.row_spacing = Decimal('1.0') + model.laser_frequency = Decimal('2000.0') + model.scan_velocity = Decimal('100.0') + model.scan_angles = 4 # 0, 45, 90, 135 degrees + + print("Scan Model Configuration:") + print(f" AoI Origin: ({model.x_origin}, {model.y_origin})") + print(f" AoI Size: {model.x_delta} x {model.y_delta}") + print(f" Row Spacing: {model.row_spacing}") + print(f" Optical Origin: ({model.optical_x_origin}, {model.optical_y_origin})") + print(f" Scan Angles: {model.get_angle_list()}") + print(f" Rows Required: {model.rows_required}") + print() + + # Verify zero-angle scan (should be horizontal lines) + print("Zero-Angle Scan (first 3 lines):") + for i, coords in enumerate(model.scan_coordinates[:3]): + print(f" Line {i}: ({coords[0]}, {coords[1]}) -> ({coords[2]}, {coords[3]})") + print() + + # Verify rotated scans + print("Rotated Scans (first line of each angle):") + for angle_idx, angle_deg in enumerate(model.get_angle_list()): + if angle_idx < len(model.rotated_coordinates): + rotated_scan = model.rotated_coordinates[angle_idx] + if rotated_scan: + coords = rotated_scan[0] + print(f" Angle {angle_deg}°: ({coords[0]:.3f}, {coords[1]:.3f}) -> ({coords[2]:.3f}, {coords[3]:.3f})") + print(f" Number of lines: {len(rotated_scan)}") + print() + + # Verify that scans are horizontal (+x direction) + print("Verifying horizontal scan direction:") + for angle_idx, angle_deg in enumerate(model.get_angle_list()): + if angle_idx < len(model.rotated_coordinates): + rotated_scan = model.rotated_coordinates[angle_idx] + all_horizontal = True + for coords in rotated_scan: + # Check if y_start == y_end (horizontal line) + if abs(coords[1] - coords[3]) > 1e-10: + all_horizontal = False + break + status = "✓" if all_horizontal else "✗" + print(f" Angle {angle_deg}°: {status} All lines horizontal") + print() + + print("Test completed successfully!") + +if __name__ == "__main__": + test_rotated_aoi() diff --git a/tests/test_status_updates.py b/tests/test_status_updates.py new file mode 100644 index 0000000..a6a2199 --- /dev/null +++ b/tests/test_status_updates.py @@ -0,0 +1,172 @@ +""" +Test script to examine raw status update packets from the BBD202 controller. + +This script: +1. Connects to the controller at a low level +2. Sends HW_START_UPDATEMSGS to enable automatic status updates +3. Captures and displays raw packets to verify the format matches our parsing +""" + +import sys +import time +import struct +from hardware.bbd202 import MotionController, MsgId + + +def hexdump(data: bytes, prefix: str = "") -> str: + """Format bytes as hex dump.""" + hex_str = " ".join(f"{b:02X}" for b in data) + ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in data) + return f"{prefix}{hex_str} |{ascii_str}|" + + +def decode_status_update(data: bytes) -> dict: + """Decode a MOT_GET_USTATUSUPDATE message data payload.""" + if len(data) < 14: + return {"error": f"Data too short: {len(data)} bytes, expected 14"} + + chan_ident = struct.unpack('= vref: + print(f" ERROR: V_thermistor >= Vref") + continue + + r_thermistor = r_series * v_thermistor / (vref - v_thermistor) + print(f" R_thermistor = {r_thermistor:.2f} Ω") + + if r_thermistor <= 0: + print(f" ERROR: Invalid resistance") + continue + + # Steinhart-Hart + ln_r = math.log(r_thermistor) + inv_t = STEINHART_A + STEINHART_B * ln_r + STEINHART_C * (ln_r ** 3) + temp_k = 1.0 / inv_t + temp_c = temp_k - 273.15 + + print(f" ln(R) = {ln_r:.6f}") + print(f" Temperature = {temp_c:.2f} °C") + +def test_current_scaling(raw_adc): + """Test current scaling.""" + print(f"\n{'='*60}") + print(f"Testing Current Scaling with RAW ADC = {raw_adc}") + print(f"{'='*60}") + + current = raw_adc * 12.0 * ADC_TO_VOLTS + print(f"Current = {current:.6f} A") + +if __name__ == '__main__': + # Test with the reported raw values + test_main_temp_calculation(2) + test_main_temp_calculation(3) + test_current_scaling(0) + test_current_scaling(100) + test_current_scaling(4095) diff --git a/tools/genesis_laser_control.py b/tools/genesis_laser_control.py new file mode 100755 index 0000000..cfdab4c --- /dev/null +++ b/tools/genesis_laser_control.py @@ -0,0 +1,828 @@ +#!/usr/bin/env python3 +""" +Genesis SLM MX 532 Laser Control Application +============================================= + +This application provides comprehensive control of a Genesis SLM MX 532 laser +over serial port using NXP I2C-over-serial protocol. + +Protocol Overview: +----------------- +The laser uses NXP I2C-over-serial protocol with packet format: +[0x53] [I2C_ADDR] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50] + +For reads: +[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50] + +I2C Devices: +----------- +- X9119 digital potentiometer at 0x152 - laser current control +- PCA9555 I/O expander at 0x14a - digital I/O +- AD5254 digital potentiometer at 0x158 - limits control +- ADS7828 ADC at 0x190 - sensor readings +- M24C64 EEPROM at 0x2a4 - configuration storage + +Usage: +------ +python genesis_laser_control.py + +Author: Claude +Date: 2026-01-24 +""" + +import sys +import time +import struct +from datetime import datetime +from typing import Optional, List, Tuple +from enum import IntEnum + +import serial +from serial.tools import list_ports +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QTabWidget, QLabel, QSlider, QPushButton, QSpinBox, QCheckBox, + QComboBox, QTextEdit, QLineEdit, QGroupBox, QGridLayout, + QMessageBox, QStatusBar, QProgressBar +) +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSettings +from PyQt6.QtGui import QFont, QPalette, QColor + +# Import core hardware control classes +from hardware.genesis_core import ( + I2CAddress, PCA9555Register, ControlBitmask, + SerialComm, I2CProtocol, I2CDevices, LaserControl +) + + +# ============================================================================ +# Main Window GUI +# ============================================================================ + +class MainWindow(QMainWindow): + """Main application window with tabbed interface""" + + def __init__(self): + super().__init__() + + # Initialize backend + self.serial_comm = SerialComm() + self.i2c_protocol = I2CProtocol(self.serial_comm) + self.i2c_devices = I2CDevices(self.i2c_protocol) + self.laser_control = LaserControl(self.i2c_devices) + + # Settings for persistence + self.settings = QSettings("Genesis", "LaserControl") + + # Monitoring timer + self.monitor_timer = QTimer() + self.monitor_timer.timeout.connect(self.update_monitoring) + self.monitor_enabled = False + + # Initialize UI + self.init_ui() + self.apply_stylesheet() + + # Restore settings + self.restore_settings() + + # Initial state: all controls disabled + self.update_control_states() + + def init_ui(self): + """Initialize user interface""" + self.setWindowTitle("Genesis SLM MX 532 Laser Control") + self.setMinimumSize(800, 600) + + # Central widget with tabs + central_widget = QWidget() + self.setCentralWidget(central_widget) + + layout = QVBoxLayout(central_widget) + + # Tab widget + self.tabs = QTabWidget() + layout.addWidget(self.tabs) + + # Create tabs + self.tab_basic = self.create_basic_controls_tab() + self.tab_monitoring = self.create_monitoring_tab() + self.tab_advanced = self.create_advanced_tab() + self.tab_config = self.create_configuration_tab() + + self.tabs.addTab(self.tab_basic, "Basic Controls") + self.tabs.addTab(self.tab_monitoring, "Monitoring") + self.tabs.addTab(self.tab_advanced, "Advanced") + self.tabs.addTab(self.tab_config, "Configuration") + + # Status bar + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Disconnected") + + def create_basic_controls_tab(self) -> QWidget: + """Create basic controls tab""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Current control group + current_group = QGroupBox("Current Control") + current_layout = QGridLayout() + + current_layout.addWidget(QLabel("Current Command (0-1023):"), 0, 0) + + self.current_slider = QSlider(Qt.Orientation.Horizontal) + self.current_slider.setMinimum(0) + self.current_slider.setMaximum(1023) + self.current_slider.setValue(0) + self.current_slider.setToolTip("Set laser current (0-1023)") + self.current_slider.valueChanged.connect(self.on_current_changed) + current_layout.addWidget(self.current_slider, 0, 1) + + self.current_spinbox = QSpinBox() + self.current_spinbox.setMinimum(0) + self.current_spinbox.setMaximum(1023) + self.current_spinbox.setValue(0) + self.current_spinbox.valueChanged.connect(self.current_slider.setValue) + current_layout.addWidget(self.current_spinbox, 0, 2) + + self.current_percent_label = QLabel("0.0%") + current_layout.addWidget(self.current_percent_label, 0, 3) + + current_group.setLayout(current_layout) + layout.addWidget(current_group) + + # Power control group + power_group = QGroupBox("Power Control") + power_layout = QGridLayout() + + power_layout.addWidget(QLabel("Power Command (0-1023):"), 0, 0) + + self.power_slider = QSlider(Qt.Orientation.Horizontal) + self.power_slider.setMinimum(0) + self.power_slider.setMaximum(1023) + self.power_slider.setValue(0) + self.power_slider.setToolTip("Set laser power command (0-1023)") + self.power_slider.valueChanged.connect(self.on_power_changed) + power_layout.addWidget(self.power_slider, 0, 1) + + self.power_spinbox = QSpinBox() + self.power_spinbox.setMinimum(0) + self.power_spinbox.setMaximum(1023) + self.power_spinbox.setValue(0) + self.power_spinbox.valueChanged.connect(self.power_slider.setValue) + power_layout.addWidget(self.power_spinbox, 0, 2) + + power_group.setLayout(power_layout) + layout.addWidget(power_group) + + # Toggle controls group + toggle_group = QGroupBox("Digital Controls") + toggle_layout = QGridLayout() + + self.shutter_btn = QPushButton("Shutter: CLOSED") + self.shutter_btn.setCheckable(True) + self.shutter_btn.setToolTip("Open/close laser shutter") + self.shutter_btn.clicked.connect(self.on_shutter_toggled) + toggle_layout.addWidget(self.shutter_btn, 0, 0) + + self.keyswitch_btn = QPushButton("Keyswitch: OFF") + self.keyswitch_btn.setCheckable(True) + self.keyswitch_btn.setToolTip("Enable/disable keyswitch") + self.keyswitch_btn.clicked.connect(self.on_keyswitch_toggled) + toggle_layout.addWidget(self.keyswitch_btn, 0, 1) + + self.remote_enable_btn = QPushButton("Remote: DISABLED") + self.remote_enable_btn.setCheckable(True) + self.remote_enable_btn.setToolTip("Enable/disable remote control") + self.remote_enable_btn.clicked.connect(self.on_remote_enable_toggled) + toggle_layout.addWidget(self.remote_enable_btn, 1, 0) + + self.analog_enable_btn = QPushButton("Analog Input: DISABLED") + self.analog_enable_btn.setCheckable(True) + self.analog_enable_btn.setToolTip("Enable/disable analog input") + self.analog_enable_btn.clicked.connect(self.on_analog_enable_toggled) + toggle_layout.addWidget(self.analog_enable_btn, 1, 1) + + self.current_mode_btn = QPushButton("Current Mode: OFF") + self.current_mode_btn.setCheckable(True) + self.current_mode_btn.setToolTip("Enable/disable current mode") + self.current_mode_btn.clicked.connect(self.on_current_mode_toggled) + toggle_layout.addWidget(self.current_mode_btn, 2, 0) + + toggle_group.setLayout(toggle_layout) + layout.addWidget(toggle_group) + + # Emergency stop + self.emergency_stop_btn = QPushButton("EMERGENCY STOP") + self.emergency_stop_btn.setStyleSheet("background-color: #ff0000; color: white; font-weight: bold; font-size: 14pt;") + self.emergency_stop_btn.clicked.connect(self.on_emergency_stop) + self.emergency_stop_btn.setEnabled(True) # Always enabled + layout.addWidget(self.emergency_stop_btn) + + layout.addStretch() + + return tab + + def create_monitoring_tab(self) -> QWidget: + """Create monitoring tab""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Auto-refresh control + refresh_layout = QHBoxLayout() + self.auto_refresh_check = QCheckBox("Auto-refresh") + self.auto_refresh_check.stateChanged.connect(self.on_auto_refresh_toggled) + refresh_layout.addWidget(self.auto_refresh_check) + + refresh_layout.addWidget(QLabel("Interval (ms):")) + self.refresh_interval_spin = QSpinBox() + self.refresh_interval_spin.setMinimum(100) + self.refresh_interval_spin.setMaximum(5000) + self.refresh_interval_spin.setValue(500) + self.refresh_interval_spin.setSingleStep(100) + refresh_layout.addWidget(self.refresh_interval_spin) + + self.refresh_now_btn = QPushButton("Refresh Now") + self.refresh_now_btn.clicked.connect(self.update_monitoring) + refresh_layout.addWidget(self.refresh_now_btn) + + refresh_layout.addStretch() + layout.addLayout(refresh_layout) + + # Readings group + readings_group = QGroupBox("Sensor Readings") + readings_layout = QGridLayout() + + readings_layout.addWidget(QLabel("Actual Current:"), 0, 0) + self.current_actual_label = QLabel("---") + readings_layout.addWidget(self.current_actual_label, 0, 1) + + readings_layout.addWidget(QLabel("Interlock Status:"), 1, 0) + self.interlock_label = QLabel("---") + readings_layout.addWidget(self.interlock_label, 1, 1) + + readings_layout.addWidget(QLabel("LDD Enable:"), 2, 0) + self.ldd_enable_label = QLabel("---") + readings_layout.addWidget(self.ldd_enable_label, 2, 1) + + readings_layout.addWidget(QLabel("PS Glue In:"), 3, 0) + self.psglue_in_label = QLabel("---") + readings_layout.addWidget(self.psglue_in_label, 3, 1) + + readings_layout.addWidget(QLabel("PS Glue Out:"), 4, 0) + self.psglue_out_label = QLabel("---") + readings_layout.addWidget(self.psglue_out_label, 4, 1) + + readings_layout.addWidget(QLabel("Head DIO:"), 5, 0) + self.head_dio_label = QLabel("---") + readings_layout.addWidget(self.head_dio_label, 5, 1) + + readings_group.setLayout(readings_layout) + layout.addWidget(readings_group) + + # Laser info group + info_group = QGroupBox("Laser Information") + info_layout = QGridLayout() + + info_layout.addWidget(QLabel("Model:"), 0, 0) + self.laser_model_label = QLabel("Genesis SLM MX 532") + info_layout.addWidget(self.laser_model_label, 0, 1) + + info_layout.addWidget(QLabel("Wavelength:"), 1, 0) + self.wavelength_label = QLabel("532 nm") + info_layout.addWidget(self.wavelength_label, 1, 1) + + info_group.setLayout(info_layout) + layout.addWidget(info_group) + + layout.addStretch() + + return tab + + def create_advanced_tab(self) -> QWidget: + """Create advanced tab""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Raw I2C packet sender + packet_group = QGroupBox("Raw I2C Packet Sender") + packet_layout = QGridLayout() + + packet_layout.addWidget(QLabel("I2C Address (hex):"), 0, 0) + self.i2c_addr_edit = QLineEdit("52") + self.i2c_addr_edit.setMaximumWidth(100) + packet_layout.addWidget(self.i2c_addr_edit, 0, 1) + + packet_layout.addWidget(QLabel("Command (hex):"), 1, 0) + self.i2c_cmd_edit = QLineEdit("a0") + self.i2c_cmd_edit.setMaximumWidth(200) + packet_layout.addWidget(self.i2c_cmd_edit, 1, 1) + + packet_layout.addWidget(QLabel("Data (hex):"), 2, 0) + self.i2c_data_edit = QLineEdit("00 00") + packet_layout.addWidget(self.i2c_data_edit, 2, 1) + + self.send_packet_btn = QPushButton("Send Packet") + self.send_packet_btn.clicked.connect(self.on_send_raw_packet) + packet_layout.addWidget(self.send_packet_btn, 3, 0, 1, 2) + + self.packet_response_label = QLabel("Response: ---") + packet_layout.addWidget(self.packet_response_label, 4, 0, 1, 2) + + packet_group.setLayout(packet_layout) + layout.addWidget(packet_group) + + # Packet log + log_group = QGroupBox("Packet Capture Log") + log_layout = QVBoxLayout() + + log_controls = QHBoxLayout() + self.clear_log_btn = QPushButton("Clear Log") + self.clear_log_btn.clicked.connect(self.on_clear_log) + log_controls.addWidget(self.clear_log_btn) + log_controls.addStretch() + log_layout.addLayout(log_controls) + + self.packet_log_text = QTextEdit() + self.packet_log_text.setReadOnly(True) + self.packet_log_text.setFont(QFont("Courier", 9)) + log_layout.addWidget(self.packet_log_text) + + log_group.setLayout(log_layout) + layout.addWidget(log_group) + + # Update log periodically + self.log_timer = QTimer() + self.log_timer.timeout.connect(self.update_packet_log) + self.log_timer.start(1000) # Update every second + + return tab + + def create_configuration_tab(self) -> QWidget: + """Create configuration tab""" + tab = QWidget() + layout = QVBoxLayout(tab) + + # Serial port configuration + serial_group = QGroupBox("Serial Port Configuration") + serial_layout = QGridLayout() + + serial_layout.addWidget(QLabel("Port:"), 0, 0) + self.port_combo = QComboBox() + self.refresh_ports() + serial_layout.addWidget(self.port_combo, 0, 1) + + self.refresh_ports_btn = QPushButton("Refresh Ports") + self.refresh_ports_btn.clicked.connect(self.refresh_ports) + serial_layout.addWidget(self.refresh_ports_btn, 0, 2) + + serial_layout.addWidget(QLabel("Baud Rate:"), 1, 0) + self.baudrate_combo = QComboBox() + self.baudrate_combo.addItems(["9600", "19200", "38400", "57600", "115200"]) + self.baudrate_combo.setCurrentText("9600") + serial_layout.addWidget(self.baudrate_combo, 1, 1) + + self.connect_btn = QPushButton("Connect") + self.connect_btn.clicked.connect(self.on_connect_clicked) + serial_layout.addWidget(self.connect_btn, 2, 0, 1, 3) + + serial_group.setLayout(serial_layout) + layout.addWidget(serial_group) + + # DTR/RTS control + control_group = QGroupBox("Serial Control Lines") + control_layout = QVBoxLayout() + + self.dtr_check = QCheckBox("DTR (Data Terminal Ready)") + self.dtr_check.stateChanged.connect(self.on_dtr_changed) + control_layout.addWidget(self.dtr_check) + + self.rts_check = QCheckBox("RTS (Request To Send)") + self.rts_check.stateChanged.connect(self.on_rts_changed) + control_layout.addWidget(self.rts_check) + + control_group.setLayout(control_layout) + layout.addWidget(control_group) + + # About section + about_group = QGroupBox("About") + about_layout = QVBoxLayout() + + about_text = QLabel( + "Genesis SLM MX 532 Laser Control\n\n" + "Protocol: NXP I2C-over-serial\n" + "Baud Rate: 9600 (default)\n\n" + "This application provides comprehensive control of the Genesis laser\n" + "using I2C devices over serial communication." + ) + about_layout.addWidget(about_text) + + about_group.setLayout(about_layout) + layout.addWidget(about_group) + + layout.addStretch() + + return tab + + # ------------------------------------------------------------------------ + # Event Handlers - Basic Controls + # ------------------------------------------------------------------------ + + def on_current_changed(self, value: int): + """Handle current slider change""" + self.current_spinbox.setValue(value) + percent = (value / 1023.0) * 100.0 + self.current_percent_label.setText(f"{percent:.1f}%") + + if self.serial_comm.is_connected(): + if self.laser_control.set_current(value): + self.status_bar.showMessage(f"Current set to {value} ({percent:.1f}%)", 2000) + + def on_power_changed(self, value: int): + """Handle power slider change""" + self.power_spinbox.setValue(value) + + if self.serial_comm.is_connected(): + if self.laser_control.set_power_cmd(value): + self.status_bar.showMessage(f"Power command set to {value}", 2000) + + def on_shutter_toggled(self, checked: bool): + """Handle shutter toggle""" + if checked and self.current_slider.value() > 0: + reply = QMessageBox.question( + self, + "Confirm Shutter Open", + "Current is set above 0. Are you sure you want to open the shutter?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.No: + self.shutter_btn.setChecked(False) + return + + if self.laser_control.set_shutter(checked): + self.shutter_btn.setText("Shutter: OPEN" if checked else "Shutter: CLOSED") + self.status_bar.showMessage(f"Shutter {'opened' if checked else 'closed'}", 2000) + else: + self.shutter_btn.setChecked(not checked) + + def on_keyswitch_toggled(self, checked: bool): + """Handle keyswitch toggle""" + if self.laser_control.set_keyswitch(checked): + self.keyswitch_btn.setText("Keyswitch: ON" if checked else "Keyswitch: OFF") + self.status_bar.showMessage(f"Keyswitch {'enabled' if checked else 'disabled'}", 2000) + else: + self.keyswitch_btn.setChecked(not checked) + + def on_remote_enable_toggled(self, checked: bool): + """Handle remote enable toggle""" + if self.laser_control.set_remote_enable(checked): + self.remote_enable_btn.setText("Remote: ENABLED" if checked else "Remote: DISABLED") + self.status_bar.showMessage(f"Remote control {'enabled' if checked else 'disabled'}", 2000) + else: + self.remote_enable_btn.setChecked(not checked) + + def on_analog_enable_toggled(self, checked: bool): + """Handle analog enable toggle""" + if self.laser_control.set_analog_enable(checked): + self.analog_enable_btn.setText("Analog Input: ENABLED" if checked else "Analog Input: DISABLED") + self.status_bar.showMessage(f"Analog input {'enabled' if checked else 'disabled'}", 2000) + else: + self.analog_enable_btn.setChecked(not checked) + + def on_current_mode_toggled(self, checked: bool): + """Handle current mode toggle""" + if self.laser_control.set_current_mode(checked): + self.current_mode_btn.setText("Current Mode: ON" if checked else "Current Mode: OFF") + self.status_bar.showMessage(f"Current mode {'enabled' if checked else 'disabled'}", 2000) + else: + self.current_mode_btn.setChecked(not checked) + + def on_emergency_stop(self): + """Handle emergency stop button""" + if self.laser_control.emergency_stop(): + # Reset UI to safe state + self.current_slider.setValue(0) + self.power_slider.setValue(0) + self.shutter_btn.setChecked(False) + self.shutter_btn.setText("Shutter: CLOSED") + self.keyswitch_btn.setChecked(False) + self.keyswitch_btn.setText("Keyswitch: OFF") + + self.status_bar.showMessage("EMERGENCY STOP ACTIVATED", 5000) + QMessageBox.warning(self, "Emergency Stop", "Emergency stop activated!\nLaser is now in safe state.") + + # ------------------------------------------------------------------------ + # Event Handlers - Monitoring + # ------------------------------------------------------------------------ + + def on_auto_refresh_toggled(self, state): + """Handle auto-refresh toggle""" + if state: + interval = self.refresh_interval_spin.value() + self.monitor_timer.start(interval) + self.monitor_enabled = True + else: + self.monitor_timer.stop() + self.monitor_enabled = False + + def update_monitoring(self): + """Update monitoring readings""" + if not self.serial_comm.is_connected(): + return + + # Read actual current + current = self.laser_control.get_current_actual() + if current is not None: + self.current_actual_label.setText(f"{current} counts") + else: + self.current_actual_label.setText("Error reading") + + # Read interlock status + interlock = self.laser_control.get_interlock_status() + if interlock is not None: + if interlock: + self.interlock_label.setText("OK") + self.interlock_label.setStyleSheet("color: green; font-weight: bold;") + else: + self.interlock_label.setText("FAULT") + self.interlock_label.setStyleSheet("color: red; font-weight: bold;") + else: + self.interlock_label.setText("Error reading") + self.interlock_label.setStyleSheet("") + + # Read LDD enable + ldd = self.laser_control.get_ldd_enable_status() + if ldd is not None: + self.ldd_enable_label.setText("Enabled" if ldd else "Disabled") + else: + self.ldd_enable_label.setText("Error reading") + + # Read PS glue status + psglue_in = self.laser_control.get_psglue_in_status() + if psglue_in is not None: + self.psglue_in_label.setText(f"0x{psglue_in:02x}") + else: + self.psglue_in_label.setText("Error reading") + + psglue_out = self.laser_control.get_psglue_out_status() + if psglue_out is not None: + self.psglue_out_label.setText(f"0x{psglue_out:02x}") + else: + self.psglue_out_label.setText("Error reading") + + # Read head DIO + head_dio = self.laser_control.get_head_dio_status() + if head_dio is not None: + self.head_dio_label.setText(f"0x{head_dio:02x}") + else: + self.head_dio_label.setText("Error reading") + + # ------------------------------------------------------------------------ + # Event Handlers - Advanced + # ------------------------------------------------------------------------ + + def on_send_raw_packet(self): + """Handle raw packet send""" + if not self.serial_comm.is_connected(): + QMessageBox.warning(self, "Not Connected", "Please connect to serial port first.") + return + + try: + # Parse inputs + addr = int(self.i2c_addr_edit.text(), 16) + cmd_hex = self.i2c_cmd_edit.text().replace(" ", "") + cmd = bytes.fromhex(cmd_hex) + + data_hex = self.i2c_data_edit.text().replace(" ", "") + data = bytes.fromhex(data_hex) if data_hex else b'' + + # Send packet + if self.i2c_protocol.write(addr, cmd, data): + self.packet_response_label.setText("Response: Packet sent successfully") + self.status_bar.showMessage("Raw packet sent", 2000) + else: + self.packet_response_label.setText("Response: Send failed") + + except ValueError as e: + QMessageBox.warning(self, "Invalid Input", f"Invalid hex value: {e}") + + def on_clear_log(self): + """Handle clear log button""" + self.serial_comm.clear_packet_log() + self.packet_log_text.clear() + + def update_packet_log(self): + """Update packet log display""" + if not self.serial_comm.is_connected(): + return + + log_entries = self.serial_comm.get_packet_log(100) + if log_entries: + self.packet_log_text.setPlainText("\n".join(log_entries)) + # Scroll to bottom + scrollbar = self.packet_log_text.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + # ------------------------------------------------------------------------ + # Event Handlers - Configuration + # ------------------------------------------------------------------------ + + def on_connect_clicked(self): + """Handle connect/disconnect button""" + if self.serial_comm.is_connected(): + # Disconnect + self.laser_control.enter_safe_state() + self.serial_comm.disconnect() + self.connect_btn.setText("Connect") + self.status_bar.showMessage("Disconnected") + self.update_control_states() + else: + # Connect + port = self.port_combo.currentText() + baudrate = int(self.baudrate_combo.currentText()) + + if self.serial_comm.connect(port, baudrate): + self.connect_btn.setText("Disconnect") + self.status_bar.showMessage(f"Connected to {port} at {baudrate} baud") + self.update_control_states() + else: + QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}") + + def on_dtr_changed(self, state): + """Handle DTR checkbox change""" + if self.serial_comm.is_connected(): + self.serial_comm.port.dtr = bool(state) + + def on_rts_changed(self, state): + """Handle RTS checkbox change""" + if self.serial_comm.is_connected(): + self.serial_comm.port.rts = bool(state) + + def refresh_ports(self): + """Refresh available serial ports""" + self.port_combo.clear() + ports = list_ports.comports() + for port in ports: + self.port_combo.addItem(port.device) + + # Add default if no ports found + if self.port_combo.count() == 0: + self.port_combo.addItem("/dev/ttyUSB0") + + # ------------------------------------------------------------------------ + # UI Update Functions + # ------------------------------------------------------------------------ + + def update_control_states(self): + """Enable/disable controls based on connection state""" + connected = self.serial_comm.is_connected() + + # Basic controls + self.current_slider.setEnabled(connected) + self.current_spinbox.setEnabled(connected) + self.power_slider.setEnabled(connected) + self.power_spinbox.setEnabled(connected) + self.shutter_btn.setEnabled(connected) + self.keyswitch_btn.setEnabled(connected) + self.remote_enable_btn.setEnabled(connected) + self.analog_enable_btn.setEnabled(connected) + self.current_mode_btn.setEnabled(connected) + + # Monitoring controls + self.auto_refresh_check.setEnabled(connected) + self.refresh_now_btn.setEnabled(connected) + + # Advanced controls + self.send_packet_btn.setEnabled(connected) + + # Configuration controls + self.port_combo.setEnabled(not connected) + self.baudrate_combo.setEnabled(not connected) + self.dtr_check.setEnabled(connected) + self.rts_check.setEnabled(connected) + + def apply_stylesheet(self): + """Apply custom stylesheet for professional look""" + self.setStyleSheet(""" + QGroupBox { + font-weight: bold; + border: 2px solid #cccccc; + border-radius: 5px; + margin-top: 10px; + padding-top: 10px; + } + + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + } + + QPushButton { + padding: 5px 10px; + border-radius: 3px; + background-color: #e0e0e0; + } + + QPushButton:hover { + background-color: #d0d0d0; + } + + QPushButton:pressed { + background-color: #c0c0c0; + } + + QPushButton:disabled { + background-color: #f0f0f0; + color: #a0a0a0; + } + + QPushButton:checked { + background-color: #4CAF50; + color: white; + } + + QSlider::groove:horizontal { + border: 1px solid #999999; + height: 8px; + background: #e0e0e0; + margin: 2px 0; + border-radius: 4px; + } + + QSlider::handle:horizontal { + background: #4CAF50; + border: 1px solid #5c5c5c; + width: 18px; + margin: -5px 0; + border-radius: 9px; + } + """) + + # ------------------------------------------------------------------------ + # Settings Persistence + # ------------------------------------------------------------------------ + + def save_settings(self): + """Save window geometry and settings""" + self.settings.setValue("geometry", self.saveGeometry()) + self.settings.setValue("port", self.port_combo.currentText()) + self.settings.setValue("baudrate", self.baudrate_combo.currentText()) + + def restore_settings(self): + """Restore window geometry and settings""" + geometry = self.settings.value("geometry") + if geometry: + self.restoreGeometry(geometry) + + port = self.settings.value("port") + if port: + index = self.port_combo.findText(port) + if index >= 0: + self.port_combo.setCurrentIndex(index) + + baudrate = self.settings.value("baudrate") + if baudrate: + index = self.baudrate_combo.findText(baudrate) + if index >= 0: + self.baudrate_combo.setCurrentIndex(index) + + def closeEvent(self, event): + """Handle window close event""" + if self.serial_comm.is_connected(): + reply = QMessageBox.question( + self, + "Confirm Exit", + "Laser is connected. Enter safe state and disconnect?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.Yes: + self.laser_control.enter_safe_state() + self.serial_comm.disconnect() + else: + event.ignore() + return + + self.save_settings() + event.accept() + + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +def main(): + """Main application entry point""" + app = QApplication(sys.argv) + app.setApplicationName("Genesis Laser Control") + app.setOrganizationName("Genesis") + + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/tools/genesis_laser_gui.py b/tools/genesis_laser_gui.py new file mode 100755 index 0000000..e84de04 --- /dev/null +++ b/tools/genesis_laser_gui.py @@ -0,0 +1,1445 @@ +#!/opt/srasenv/bin/python3 +""" +Genesis SLM MX 532 Laser Control Application +============================================= + +This application controls a Genesis SLM MX 532 laser via I2C-over-serial protocol. + +PROTOCOL OVERVIEW: +- Serial: /dev/ttyUSB0 @ 9600 8N1 +- Protocol: NXP I2C tunneled over serial +- Packet format: [0x53][addr][len][cmd][data][0x50] + +SAFETY WARNINGS: +- This laser has a MANUAL shutter - operate it manually +- Always close manual shutter before adjusting current +- Verify interlock status before opening manual shutter +- Use emergency stop if anything looks wrong +- Never bypass safety interlocks + +USAGE: +1. Connect serial port in Configuration tab +2. Enable Remote and Keyswitch +3. Set current to desired level +4. Manually operate shutter as needed +5. Monitor temperature and current +6. Manually close shutter when done + +For more information, see laser_control_implementation_guide.md +""" + +import sys +import time +import serial +from datetime import datetime +from typing import Optional, List +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QTabWidget, QPushButton, QSlider, QSpinBox, QLabel, QComboBox, + QLineEdit, QTextEdit, QCheckBox, QMessageBox, QGroupBox, QGridLayout +) +from PyQt6.QtCore import QObject, pyqtSignal, QTimer, Qt +from PyQt6.QtGui import QPalette, QColor + +# ============================================================================ +# CONSTANTS +# ============================================================================ + +# I2C Addresses (7-bit, write bit cleared) +ADDR_X9119_CURRENT = 0x52 +ADDR_PCA9555_PS_DIO = 0x40 +ADDR_PCA9555_HEAD_DIO = 0x44 +ADDR_PCA9555_PS_GLUE_IN = 0x48 +ADDR_PCA9555_PS_GLUE_OUT = 0x4a +ADDR_AD5254 = 0x58 +ADDR_ADS7828 = 0x90 +ADDR_EEPROM = 0xa4 +ADDR_STM32 = 0xae + +# PCA9555 Bit Masks (0x4a - PS Glue Out) +BIT_SHUTTER = 0x01 +BIT_CURRENT_MODE = 0x04 +BIT_REMOTE_ENABLE = 0x08 +BIT_ANALOG_ENABLE = 0x10 +BIT_KEYSWITCH = 0x20 + +# X9119 Commands +CMD_X9119_WRITE_WIPER = 0xa0 + +# ADS7828 Channels +CHAN_CURRENT_ACTUAL = 0x84 +CHAN_PHOTO_ACTUAL = 0xe4 +CHAN_MAIN_TEMP = 0x94 +CHAN_ETALON_TEMP = 0xa4 # TODO: Verify channel +CHAN_SHG_TEMP = 0xb4 # TODO: Verify channel + +# Scaling constants +AMPS_FULLSCALE = 12.0 +WATTS_FULLSCALE = 2.0 +POWER_LIMIT = 0.55 +ADC_TO_VOLTS = 0.000244140625 # 1/4096 + +# Temperature calibration constants (for future use) +# Steinhart-Hart: A=0.0011279, B=0.00023429, C=8.7298e-8 +# PT1000: Vref=12V, R1=10kΩ +# Currently displaying raw ADC values until circuit parameters are confirmed + +# Serial Protocol +NXP_START_BYTE = 0x53 +NXP_STOP_BYTE = 0x50 + +# ============================================================================ +# SERIAL COMMUNICATION LAYER +# ============================================================================ + +class SerialComm(QObject): + """Handles serial port communication with the laser controller.""" + + connected = pyqtSignal() + disconnected = pyqtSignal() + error = pyqtSignal(str) + data_received = pyqtSignal(bytes) + packet_sent = pyqtSignal(bytes) + + def __init__(self): + super().__init__() + self.port: Optional[serial.Serial] = None + self.is_connected = False + self.timeout = 1.0 + self.packet_log: List[str] = [] + + def connect(self, port_name: str, baudrate: int = 9600) -> bool: + """ + Connect to serial port. + + Args: + port_name: Serial port path (e.g., '/dev/ttyUSB0') + baudrate: Baud rate (default: 9600) + + Returns: + True if connection successful + """ + try: + self.port = serial.Serial( + port=port_name, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=self.timeout + ) + self.is_connected = True + self.connected.emit() + self._log_packet(f"Connected to {port_name} @ {baudrate}") + return True + except Exception as e: + self.error.emit(f"Connection failed: {str(e)}") + return False + + def disconnect(self): + """Disconnect from serial port.""" + if self.port and self.port.is_open: + self.port.close() + self.is_connected = False + self.disconnected.emit() + self._log_packet("Disconnected") + + def write_packet(self, data: bytes) -> bool: + """ + Write packet to serial port. + + Args: + data: Bytes to write + + Returns: + True if write successful + """ + if not self.is_connected or not self.port: + self.error.emit("Not connected") + return False + + try: + self.port.write(data) + self.packet_sent.emit(data) + self._log_packet(f"TX: {data.hex(' ')}") + return True + except Exception as e: + self.error.emit(f"Write failed: {str(e)}") + return False + + def read_packet(self, length: int) -> Optional[bytes]: + """ + Read packet from serial port. + + Args: + length: Number of bytes to read + + Returns: + Bytes read or None on error + """ + if not self.is_connected or not self.port: + self.error.emit("Not connected") + return None + + try: + data = self.port.read(length) + if data: + self.data_received.emit(data) + self._log_packet(f"RX: {data.hex(' ')}") + return data + except Exception as e: + self.error.emit(f"Read failed: {str(e)}") + return None + + def _log_packet(self, message: str): + """Log packet with timestamp.""" + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + log_entry = f"[{timestamp}] {message}" + self.packet_log.append(log_entry) + + def get_packet_log(self) -> List[str]: + """Get packet log.""" + return self.packet_log.copy() + + def clear_packet_log(self): + """Clear packet log.""" + self.packet_log.clear() + +# ============================================================================ +# I2C PROTOCOL LAYER +# ============================================================================ + +class I2CProtocol(QObject): + """Implements NXP I2C-over-serial protocol.""" + + def __init__(self, serial_comm: SerialComm): + super().__init__() + self.serial = serial_comm + + def nxp_write(self, i2c_addr_write: int, cmd: int, data: int, data_len: int) -> bool: + """ + Build and send NXP I2C write packet. + + Args: + i2c_addr_write: I2C device address (write bit cleared) + cmd: Command byte(s) - int for 1 or 2 bytes + data: Data value to write + data_len: 1, 2, or 4 bytes + + Returns: + True if successful + """ + # Determine command length + if cmd <= 0xFF: + cmd_bytes = bytes([cmd]) + else: + cmd_bytes = cmd.to_bytes(2, 'big') + + # Convert data to bytes (big-endian) + data_bytes = data.to_bytes(data_len, 'big') + + # Calculate total length + total_len = len(cmd_bytes) + len(data_bytes) + + # Build packet: [0x53][addr][len][cmd...][data...][0x50] + packet = bytes([ + NXP_START_BYTE, + i2c_addr_write, + total_len + ]) + cmd_bytes + data_bytes + bytes([NXP_STOP_BYTE]) + + return self.serial.write_packet(packet) + + def nxp_read(self, i2c_addr_write: int, cmd: int, cmd_len: int, data_len: int) -> Optional[int]: + """ + Build and send NXP I2C read packet. + + Args: + i2c_addr_write: I2C device address (write bit cleared) + cmd: Command byte(s) + cmd_len: 1 or 2 bytes + data_len: Number of bytes to read + + Returns: + Integer value read from device or None on error + """ + # Convert command to bytes + if cmd_len == 1: + cmd_bytes = bytes([cmd]) + else: + cmd_bytes = cmd.to_bytes(2, 'big') + + # Build packet: [0x53][addr][cmd_len][cmd...][0x53][addr|0x01][data_len][0x50] + packet = bytes([ + NXP_START_BYTE, + i2c_addr_write, + cmd_len + ]) + cmd_bytes + bytes([ + NXP_START_BYTE, + i2c_addr_write | 0x01, # Read address + data_len, + NXP_STOP_BYTE + ]) + + # Send packet + if not self.serial.write_packet(packet): + return None + + # Read response + response = self.serial.read_packet(data_len) + if not response or len(response) != data_len: + return None + + # Convert bytes to integer (big-endian) + return int.from_bytes(response, 'big') + + # Read strategies + + def i2c_read_one(self, addr: int, cmd: int, data_len: int) -> Optional[int]: + """ + Single read, no filtering. Use for digital I/O. + + Args: + addr: I2C address + cmd: Command byte(s) + data_len: Number of bytes to read + + Returns: + Value read or None on error + """ + cmd_len = 1 if cmd <= 0xFF else 2 + return self.nxp_read(addr, cmd, cmd_len, data_len) + + def i2c_read_match_two(self, addr: int, cmd: int, data_len: int, max_attempts: int = 5) -> Optional[int]: + """ + Read until 2 values match (up to max_attempts). Most reliable. + Use for EEPROM reads, configuration values. + + Args: + addr: I2C address + cmd: Command byte(s) + data_len: Number of bytes to read + max_attempts: Maximum read attempts + + Returns: + Matched value or first value if no match + """ + cmd_len = 1 if cmd <= 0xFF else 2 + readings = [] + + for attempt in range(max_attempts): + value = self.nxp_read(addr, cmd, cmd_len, data_len) + if value is None: + continue + readings.append(value) + if readings.count(value) >= 2: + return value + time.sleep(0.01) + + return readings[0] if readings else None + + def i2c_read_discard_high_low(self, addr: int, cmd: int, data_len: int) -> Optional[int]: + """ + Read 3 times, return median. Filters noise. + Use for ADC readings (current, temperature, voltage). + + Args: + addr: I2C address + cmd: Command byte(s) + data_len: Number of bytes to read + + Returns: + Median value or None on error + """ + cmd_len = 1 if cmd <= 0xFF else 2 + readings = [] + + for _ in range(3): + value = self.nxp_read(addr, cmd, cmd_len, data_len) + if value is not None: + readings.append(value) + time.sleep(0.01) + + if len(readings) < 3: + return readings[0] if readings else None + + return sorted(readings)[1] # Median + + # Device-specific functions + + def pca9555_read_port(self, addr: int, port: int) -> Optional[int]: + """Read PCA9555 port register (0x00-0x07).""" + return self.i2c_read_one(addr, port, 1) + + def pca9555_write_port(self, addr: int, port: int, value: int) -> bool: + """Write PCA9555 port register.""" + return self.nxp_write(addr, port, value, 1) + + def pca9555_set_bit(self, addr: int, port: int, bitmask: int, state: bool) -> bool: + """ + Set or clear specific bit on PCA9555. + Reads current value, modifies bit, writes back. + + Args: + addr: I2C address + port: Port number (0 or 1) + bitmask: Bit mask + state: True to set, False to clear + + Returns: + True if successful + """ + # Read current output port value + current = self.pca9555_read_port(addr, 0x02 + port) + if current is None: + return False + + # Modify bit + if state: + new_value = current | bitmask + else: + new_value = current & ~bitmask + + # Write back + return self.pca9555_write_port(addr, 0x02 + port, new_value) + + def x9119_write_wiper(self, addr: int, value: int) -> bool: + """ + Set X9119 wiper position (0-1023). + + Args: + addr: I2C address + value: Wiper position (0-1023) + + Returns: + True if successful + """ + if value > 1023: + raise ValueError("X9119 value must be 0-1023") + return self.nxp_write(addr, CMD_X9119_WRITE_WIPER, value, 2) + + def ads7828_read(self, addr: int, channel: int) -> Optional[int]: + """ + Read ADS7828 ADC channel. + + Args: + addr: I2C address + channel: Channel command byte + + Returns: + 12-bit ADC value or None on error + """ + return self.i2c_read_discard_high_low(addr, channel, 2) + +# ============================================================================ +# LASER CONTROL LAYER +# ============================================================================ + +class LaserControl(QObject): + """High-level laser control functions.""" + + status_changed = pyqtSignal(str) + + def __init__(self, i2c_protocol: I2CProtocol): + super().__init__() + self.i2c = i2c_protocol + + # Control functions + + def set_current(self, value: int) -> bool: + """ + CCMD= - Set laser current (0-1023). + + Args: + value: Current setting (0-1023) + + Returns: + True if successful + """ + if not 0 <= value <= 1023: + return False + return self.i2c.x9119_write_wiper(ADDR_X9119_CURRENT, value) + + def set_power_cmd(self, value: int) -> bool: + """ + PCMD= - Set power command (0-1023). + + Args: + value: Power setting (0-1023) + + Returns: + True if successful + """ + if not 0 <= value <= 1023: + return False + return self.i2c.x9119_write_wiper(ADDR_X9119_CURRENT, value) + + def set_shutter(self, state: bool) -> bool: + """ + SHCMD= - Control shutter (True=open, False=closed). + + Args: + state: True to open, False to close + + Returns: + True if successful + """ + return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_SHUTTER, state) + + def set_keyswitch(self, state: bool) -> bool: + """ + KSWCMD= - Control keyswitch (True=on, False=off). + + Args: + state: True for on, False for off + + Returns: + True if successful + """ + return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_KEYSWITCH, state) + + def set_remote_enable(self, state: bool) -> bool: + """ + REM= - Enable remote control (True=enabled). + + Args: + state: True to enable, False to disable + + Returns: + True if successful + """ + return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_REMOTE_ENABLE, state) + + def set_analog_enable(self, state: bool) -> bool: + """ + ANACMD= - Enable analog input (True=enabled). + + Args: + state: True to enable, False to disable + + Returns: + True if successful + """ + return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_ANALOG_ENABLE, state) + + def set_current_mode(self, state: bool) -> bool: + """ + CMODECMD= - Set current mode (True=enabled). + + Args: + state: True to enable, False to disable + + Returns: + True if successful + """ + return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_CURRENT_MODE, state) + + # Monitoring functions + + def get_current_actual(self) -> Optional[float]: + """ + Read actual current from ADC. + + Returns: + Current in Amps or None on error + """ + raw = self.i2c.ads7828_read(ADDR_ADS7828, CHAN_CURRENT_ACTUAL) + if raw is None: + return None + return raw * AMPS_FULLSCALE * ADC_TO_VOLTS + + def get_power_actual(self) -> Optional[float]: + """ + Read actual power from ADC. + + Returns: + Power in Watts or None on error + """ + raw = self.i2c.ads7828_read(ADDR_ADS7828, CHAN_PHOTO_ACTUAL) + if raw is None: + return None + return raw * WATTS_FULLSCALE * ADC_TO_VOLTS + + def get_main_temp(self) -> Optional[int]: + """ + Read main crystal temperature (raw ADC value). + + Returns: + Raw ADC value (0-4095) or None on error + """ + return self.i2c.ads7828_read(ADDR_ADS7828, CHAN_MAIN_TEMP) + + def get_etalon_temp(self) -> Optional[int]: + """ + Read etalon temperature (raw ADC value). + + Returns: + Raw ADC value (0-4095) or None on error + """ + return self.i2c.ads7828_read(ADDR_ADS7828, CHAN_ETALON_TEMP) + + def get_shg_temp(self) -> Optional[int]: + """ + Read SHG temperature (raw ADC value). + + Returns: + Raw ADC value (0-4095) or None on error + """ + return self.i2c.ads7828_read(ADDR_ADS7828, CHAN_SHG_TEMP) + + def get_interlock_status(self) -> Optional[bool]: + """ + Check interlock status. + + Returns: + True if OK, False if fault, None on error + """ + value = self.i2c.pca9555_read_port(ADDR_PCA9555_PS_GLUE_IN, 0x00) + if value is None: + return None + return bool(value & 0x01) + + def get_ldd_enable(self) -> Optional[bool]: + """ + Check if laser diode driver is enabled. + + Returns: + True if enabled, False if disabled, None on error + """ + value = self.i2c.pca9555_read_port(ADDR_PCA9555_PS_DIO, 0x00) + if value is None: + return None + return not bool(value & 0x01) # Inverted logic + + def get_ps_glue_out_status(self) -> Optional[int]: + """ + Read PS glue output port status. + + Returns: + Port value or None on error + """ + return self.i2c.pca9555_read_port(ADDR_PCA9555_PS_GLUE_OUT, 0x02) + + # Safety functions + + def emergency_stop(self) -> bool: + """ + Emergency stop - set current to 0, disable keyswitch. + Note: Shutter is manually operated on this laser. + + Returns: + True if all operations successful + """ + success = True + success &= self.set_current(0) + success &= self.set_keyswitch(False) + self.status_changed.emit("EMERGENCY STOP ACTIVATED") + return success + + def safe_state(self) -> bool: + """ + Set laser to safe state. + Note: Shutter is manually operated on this laser. + + Returns: + True if successful + """ + success = True + success &= self.set_current(0) + success &= self.set_analog_enable(False) + return success + + def pre_flight_check(self) -> tuple[bool, str]: + """ + Pre-flight safety check. + Note: Shutter is manually operated on this laser. + + Returns: + (safe, message) tuple + """ + # Check remote enable + status = self.get_ps_glue_out_status() + if status is None: + return False, "Cannot read status" + + if not (status & BIT_REMOTE_ENABLE): + return False, "Remote enable is OFF" + + if not (status & BIT_KEYSWITCH): + return False, "Keyswitch is OFF" + + # Check interlock + interlock = self.get_interlock_status() + if interlock is None: + return False, "Cannot read interlock status" + + if not interlock: + return False, "Interlock is OPEN" + + return True, "All checks passed" + +# ============================================================================ +# GUI - BASIC CONTROLS TAB +# ============================================================================ + +class BasicControlTab(QWidget): + """Basic laser control interface.""" + + def __init__(self, laser: LaserControl): + super().__init__() + self.laser = laser + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout() + + # Current control + current_group = QGroupBox("Current Control") + current_layout = QGridLayout() + + self.current_slider = QSlider(Qt.Orientation.Horizontal) + self.current_slider.setRange(0, 1023) + self.current_slider.setValue(0) + self.current_slider.valueChanged.connect(self.on_current_changed) + + self.current_spinbox = QSpinBox() + self.current_spinbox.setRange(0, 1023) + self.current_spinbox.setValue(0) + self.current_spinbox.valueChanged.connect(self.current_slider.setValue) + self.current_slider.valueChanged.connect(self.current_spinbox.setValue) + + self.current_percent_label = QLabel("0%") + + self.current_zero_btn = QPushButton("Set to 0") + self.current_zero_btn.clicked.connect(lambda: self.current_slider.setValue(0)) + + current_layout.addWidget(QLabel("Current:"), 0, 0) + current_layout.addWidget(self.current_slider, 0, 1) + current_layout.addWidget(self.current_spinbox, 0, 2) + current_layout.addWidget(self.current_percent_label, 0, 3) + current_layout.addWidget(self.current_zero_btn, 1, 1) + + current_group.setLayout(current_layout) + layout.addWidget(current_group) + + # Power command + power_group = QGroupBox("Power Command") + power_layout = QGridLayout() + + self.power_slider = QSlider(Qt.Orientation.Horizontal) + self.power_slider.setRange(0, 1023) + self.power_slider.setValue(0) + self.power_slider.valueChanged.connect(self.on_power_changed) + + self.power_spinbox = QSpinBox() + self.power_spinbox.setRange(0, 1023) + self.power_spinbox.setValue(0) + self.power_spinbox.valueChanged.connect(self.power_slider.setValue) + self.power_slider.valueChanged.connect(self.power_spinbox.setValue) + + power_layout.addWidget(QLabel("Power:"), 0, 0) + power_layout.addWidget(self.power_slider, 0, 1) + power_layout.addWidget(self.power_spinbox, 0, 2) + + power_group.setLayout(power_layout) + layout.addWidget(power_group) + + # Digital controls + digital_group = QGroupBox("Digital Controls") + digital_layout = QGridLayout() + + self.keyswitch_btn = QPushButton("Keyswitch: OFF") + self.keyswitch_btn.setCheckable(True) + self.keyswitch_btn.clicked.connect(self.on_keyswitch_clicked) + + self.remote_btn = QPushButton("Remote: OFF") + self.remote_btn.setCheckable(True) + self.remote_btn.clicked.connect(self.on_remote_clicked) + + self.analog_btn = QPushButton("Analog: OFF") + self.analog_btn.setCheckable(True) + self.analog_btn.clicked.connect(self.on_analog_clicked) + + self.current_mode_btn = QPushButton("Current Mode: OFF") + self.current_mode_btn.setCheckable(True) + self.current_mode_btn.clicked.connect(self.on_current_mode_clicked) + + digital_layout.addWidget(self.keyswitch_btn, 0, 0) + digital_layout.addWidget(self.remote_btn, 0, 1) + digital_layout.addWidget(self.analog_btn, 1, 0) + digital_layout.addWidget(self.current_mode_btn, 1, 1) + + digital_group.setLayout(digital_layout) + layout.addWidget(digital_group) + + # Emergency stop + self.emergency_btn = QPushButton("EMERGENCY STOP") + self.emergency_btn.setObjectName("emergency") + self.emergency_btn.clicked.connect(self.on_emergency_stop) + layout.addWidget(self.emergency_btn) + + # Status display + status_group = QGroupBox("Status") + status_layout = QVBoxLayout() + + self.current_actual_label = QLabel("Current Actual: --") + self.connection_status_label = QLabel("Connection: Disconnected") + + status_layout.addWidget(self.current_actual_label) + status_layout.addWidget(self.connection_status_label) + + status_group.setLayout(status_layout) + layout.addWidget(status_group) + + layout.addStretch() + self.setLayout(layout) + + def on_current_changed(self, value: int): + """Handle current slider change.""" + percent = (value / 1023.0) * 100 + self.current_percent_label.setText(f"{percent:.1f}%") + self.laser.set_current(value) + + def on_power_changed(self, value: int): + """Handle power slider change.""" + self.laser.set_power_cmd(value) + + def on_keyswitch_clicked(self, checked: bool): + """Handle keyswitch button click.""" + self.laser.set_keyswitch(checked) + self.keyswitch_btn.setText(f"Keyswitch: {'ON' if checked else 'OFF'}") + + def on_remote_clicked(self, checked: bool): + """Handle remote enable button click.""" + self.laser.set_remote_enable(checked) + self.remote_btn.setText(f"Remote: {'ON' if checked else 'OFF'}") + + def on_analog_clicked(self, checked: bool): + """Handle analog enable button click.""" + self.laser.set_analog_enable(checked) + self.analog_btn.setText(f"Analog: {'ON' if checked else 'OFF'}") + + def on_current_mode_clicked(self, checked: bool): + """Handle current mode button click.""" + self.laser.set_current_mode(checked) + self.current_mode_btn.setText(f"Current Mode: {'ON' if checked else 'OFF'}") + + def on_emergency_stop(self): + """Handle emergency stop button click.""" + self.laser.emergency_stop() + self.reset_controls() + + def reset_controls(self): + """Reset all controls to safe state.""" + self.current_slider.setValue(0) + self.keyswitch_btn.setChecked(False) + self.keyswitch_btn.setText("Keyswitch: OFF") + + def update_current_actual(self, value: Optional[float]): + """Update current actual display.""" + if value is not None: + self.current_actual_label.setText(f"Current Actual: {value:.3f} A") + else: + self.current_actual_label.setText("Current Actual: --") + + def set_connection_status(self, connected: bool): + """Update connection status display.""" + if connected: + self.connection_status_label.setText("Connection: Connected") + self.connection_status_label.setStyleSheet("color: #00ff00;") + else: + self.connection_status_label.setText("Connection: Disconnected") + self.connection_status_label.setStyleSheet("color: #ff0000;") + +# ============================================================================ +# GUI - MONITORING TAB +# ============================================================================ + +class MonitoringTab(QWidget): + """Real-time monitoring interface.""" + + def __init__(self, laser: LaserControl): + super().__init__() + self.laser = laser + self.auto_refresh_enabled = False + self.refresh_timer = QTimer() + self.refresh_timer.timeout.connect(self.refresh_readings) + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout() + + # Real-time readings + readings_group = QGroupBox("Real-Time Readings") + readings_layout = QGridLayout() + + self.current_actual_label = QLabel("--") + self.power_actual_label = QLabel("--") + self.main_temp_label = QLabel("--") + self.etalon_temp_label = QLabel("--") + self.shg_temp_label = QLabel("--") + self.interlock_label = QLabel("--") + self.ldd_enable_label = QLabel("--") + + readings_layout.addWidget(QLabel("Current Actual:"), 0, 0) + readings_layout.addWidget(self.current_actual_label, 0, 1) + readings_layout.addWidget(QLabel("Power Actual:"), 1, 0) + readings_layout.addWidget(self.power_actual_label, 1, 1) + readings_layout.addWidget(QLabel("Main Temperature:"), 2, 0) + readings_layout.addWidget(self.main_temp_label, 2, 1) + readings_layout.addWidget(QLabel("Etalon Temperature:"), 3, 0) + readings_layout.addWidget(self.etalon_temp_label, 3, 1) + readings_layout.addWidget(QLabel("SHG Temperature:"), 4, 0) + readings_layout.addWidget(self.shg_temp_label, 4, 1) + readings_layout.addWidget(QLabel("Interlock Status:"), 5, 0) + readings_layout.addWidget(self.interlock_label, 5, 1) + readings_layout.addWidget(QLabel("LDD Enable:"), 6, 0) + readings_layout.addWidget(self.ldd_enable_label, 6, 1) + + readings_group.setLayout(readings_layout) + layout.addWidget(readings_group) + + # Controls + controls_group = QGroupBox("Controls") + controls_layout = QVBoxLayout() + + refresh_rate_layout = QHBoxLayout() + refresh_rate_layout.addWidget(QLabel("Refresh Rate (ms):")) + self.refresh_rate_slider = QSlider(Qt.Orientation.Horizontal) + self.refresh_rate_slider.setRange(100, 2000) + self.refresh_rate_slider.setValue(500) + self.refresh_rate_slider.valueChanged.connect(self.on_refresh_rate_changed) + refresh_rate_layout.addWidget(self.refresh_rate_slider) + self.refresh_rate_label = QLabel("500") + refresh_rate_layout.addWidget(self.refresh_rate_label) + controls_layout.addLayout(refresh_rate_layout) + + self.auto_refresh_checkbox = QCheckBox("Enable Auto-Refresh") + self.auto_refresh_checkbox.stateChanged.connect(self.on_auto_refresh_changed) + controls_layout.addWidget(self.auto_refresh_checkbox) + + self.manual_refresh_btn = QPushButton("Manual Refresh") + self.manual_refresh_btn.clicked.connect(self.refresh_readings) + controls_layout.addWidget(self.manual_refresh_btn) + + controls_group.setLayout(controls_layout) + layout.addWidget(controls_group) + + layout.addStretch() + self.setLayout(layout) + + def on_refresh_rate_changed(self, value: int): + """Handle refresh rate change.""" + self.refresh_rate_label.setText(str(value)) + if self.auto_refresh_enabled: + self.refresh_timer.setInterval(value) + + def on_auto_refresh_changed(self, state: int): + """Handle auto-refresh checkbox change.""" + self.auto_refresh_enabled = bool(state) + if self.auto_refresh_enabled: + interval = self.refresh_rate_slider.value() + self.refresh_timer.start(interval) + else: + self.refresh_timer.stop() + + def refresh_readings(self): + """Refresh all sensor readings.""" + # Current actual + current = self.laser.get_current_actual() + if current is not None: + self.current_actual_label.setText(f"{current:.3f} A") + else: + self.current_actual_label.setText("--") + + # Power actual + power = self.laser.get_power_actual() + if power is not None: + self.power_actual_label.setText(f"{power:.3f} W") + else: + self.power_actual_label.setText("--") + + # Main temperature (raw ADC) + temp = self.laser.get_main_temp() + if temp is not None: + self.main_temp_label.setText(f"ADC: {temp}") + else: + self.main_temp_label.setText("--") + + # Etalon temperature (raw ADC) + etalon_temp = self.laser.get_etalon_temp() + if etalon_temp is not None: + self.etalon_temp_label.setText(f"ADC: {etalon_temp}") + else: + self.etalon_temp_label.setText("--") + + # SHG temperature (raw ADC) + shg_temp = self.laser.get_shg_temp() + if shg_temp is not None: + self.shg_temp_label.setText(f"ADC: {shg_temp}") + else: + self.shg_temp_label.setText("--") + + # Interlock status + interlock = self.laser.get_interlock_status() + if interlock is not None: + if interlock: + self.interlock_label.setText("OK") + self.interlock_label.setStyleSheet("color: #00ff00; font-weight: bold;") + else: + self.interlock_label.setText("FAULT") + self.interlock_label.setStyleSheet("color: #ff0000; font-weight: bold;") + else: + self.interlock_label.setText("--") + self.interlock_label.setStyleSheet("") + + # LDD enable + ldd = self.laser.get_ldd_enable() + if ldd is not None: + self.ldd_enable_label.setText("ON" if ldd else "OFF") + else: + self.ldd_enable_label.setText("--") + +# ============================================================================ +# GUI - ADVANCED TAB +# ============================================================================ + +class AdvancedTab(QWidget): + """Advanced I2C interface and packet monitoring.""" + + def __init__(self, i2c: I2CProtocol, serial_comm: SerialComm): + super().__init__() + self.i2c = i2c + self.serial = serial_comm + self.init_ui() + + # Update packet log periodically + self.log_timer = QTimer() + self.log_timer.timeout.connect(self.update_packet_log) + self.log_timer.start(500) + + def init_ui(self): + layout = QVBoxLayout() + + # Raw I2C interface + i2c_group = QGroupBox("Raw I2C Interface") + i2c_layout = QGridLayout() + + self.addr_input = QLineEdit() + self.addr_input.setPlaceholderText("0x52") + + self.cmd_input = QLineEdit() + self.cmd_input.setPlaceholderText("0xa0") + + self.data_input = QLineEdit() + self.data_input.setPlaceholderText("0x0100") + + self.data_len_combo = QComboBox() + self.data_len_combo.addItems(["1 byte", "2 bytes", "4 bytes"]) + self.data_len_combo.setCurrentIndex(1) + + self.write_btn = QPushButton("Write") + self.write_btn.clicked.connect(self.on_write_clicked) + + self.read_btn = QPushButton("Read") + self.read_btn.clicked.connect(self.on_read_clicked) + + self.response_display = QLineEdit() + self.response_display.setReadOnly(True) + self.response_display.setPlaceholderText("Response will appear here") + + i2c_layout.addWidget(QLabel("Device Address (hex):"), 0, 0) + i2c_layout.addWidget(self.addr_input, 0, 1) + i2c_layout.addWidget(QLabel("Command (hex):"), 1, 0) + i2c_layout.addWidget(self.cmd_input, 1, 1) + i2c_layout.addWidget(QLabel("Data (hex):"), 2, 0) + i2c_layout.addWidget(self.data_input, 2, 1) + i2c_layout.addWidget(QLabel("Data Length:"), 3, 0) + i2c_layout.addWidget(self.data_len_combo, 3, 1) + i2c_layout.addWidget(self.write_btn, 4, 0) + i2c_layout.addWidget(self.read_btn, 4, 1) + i2c_layout.addWidget(QLabel("Response:"), 5, 0) + i2c_layout.addWidget(self.response_display, 5, 1) + + i2c_group.setLayout(i2c_layout) + layout.addWidget(i2c_group) + + # Packet monitor + monitor_group = QGroupBox("Packet Monitor") + monitor_layout = QVBoxLayout() + + self.packet_log = QTextEdit() + self.packet_log.setReadOnly(True) + self.packet_log.setMaximumHeight(200) + monitor_layout.addWidget(self.packet_log) + + log_buttons = QHBoxLayout() + self.clear_log_btn = QPushButton("Clear Log") + self.clear_log_btn.clicked.connect(self.on_clear_log) + log_buttons.addWidget(self.clear_log_btn) + + self.export_log_btn = QPushButton("Export to File") + self.export_log_btn.clicked.connect(self.on_export_log) + log_buttons.addWidget(self.export_log_btn) + + monitor_layout.addLayout(log_buttons) + monitor_group.setLayout(monitor_layout) + layout.addWidget(monitor_group) + + layout.addStretch() + self.setLayout(layout) + + def on_write_clicked(self): + """Handle write button click.""" + try: + addr = int(self.addr_input.text(), 16) + cmd = int(self.cmd_input.text(), 16) + data = int(self.data_input.text(), 16) + data_len = [1, 2, 4][self.data_len_combo.currentIndex()] + + success = self.i2c.nxp_write(addr, cmd, data, data_len) + if success: + self.response_display.setText("Write successful") + else: + self.response_display.setText("Write failed") + except ValueError as e: + self.response_display.setText(f"Invalid input: {str(e)}") + + def on_read_clicked(self): + """Handle read button click.""" + try: + addr = int(self.addr_input.text(), 16) + cmd = int(self.cmd_input.text(), 16) + data_len = [1, 2, 4][self.data_len_combo.currentIndex()] + cmd_len = 1 if cmd <= 0xFF else 2 + + value = self.i2c.nxp_read(addr, cmd, cmd_len, data_len) + if value is not None: + self.response_display.setText(f"0x{value:0{data_len*2}x}") + else: + self.response_display.setText("Read failed") + except ValueError as e: + self.response_display.setText(f"Invalid input: {str(e)}") + + def update_packet_log(self): + """Update packet log display.""" + log_entries = self.serial.get_packet_log() + # Only show last 50 entries + display_entries = log_entries[-50:] + self.packet_log.setPlainText('\n'.join(display_entries)) + # Scroll to bottom + scrollbar = self.packet_log.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def on_clear_log(self): + """Clear packet log.""" + self.serial.clear_packet_log() + self.packet_log.clear() + + def on_export_log(self): + """Export packet log to file.""" + log_entries = self.serial.get_packet_log() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"laser_packet_log_{timestamp}.txt" + + try: + with open(filename, 'w') as f: + f.write('\n'.join(log_entries)) + QMessageBox.information(self, "Export Successful", + f"Log exported to {filename}") + except Exception as e: + QMessageBox.warning(self, "Export Failed", + f"Failed to export log: {str(e)}") + +# ============================================================================ +# GUI - CONFIGURATION TAB +# ============================================================================ + +class ConfigTab(QWidget): + """Serial port configuration.""" + + connection_changed = pyqtSignal(bool) + + def __init__(self, serial_comm: SerialComm): + super().__init__() + self.serial = serial_comm + self.init_ui() + + # Connect signals + self.serial.connected.connect(self.on_connected) + self.serial.disconnected.connect(self.on_disconnected) + self.serial.error.connect(self.on_error) + + def init_ui(self): + layout = QVBoxLayout() + + # Serial port + port_group = QGroupBox("Serial Port") + port_layout = QGridLayout() + + self.port_combo = QComboBox() + self.port_combo.addItems(["/dev/ttyUSB1", "/dev/ttyUSB0", "/dev/ttyACM0"]) + self.port_combo.setEditable(True) + + self.baudrate_combo = QComboBox() + self.baudrate_combo.addItems(["9600", "19200", "38400", "57600", "115200"]) + self.baudrate_combo.setCurrentText("9600") + + port_layout.addWidget(QLabel("Port:"), 0, 0) + port_layout.addWidget(self.port_combo, 0, 1) + port_layout.addWidget(QLabel("Baud Rate:"), 1, 0) + port_layout.addWidget(self.baudrate_combo, 1, 1) + port_layout.addWidget(QLabel("Data Bits:"), 2, 0) + port_layout.addWidget(QLabel("8 (fixed)"), 2, 1) + port_layout.addWidget(QLabel("Parity:"), 3, 0) + port_layout.addWidget(QLabel("None (fixed)"), 3, 1) + port_layout.addWidget(QLabel("Stop Bits:"), 4, 0) + port_layout.addWidget(QLabel("1 (fixed)"), 4, 1) + + port_group.setLayout(port_layout) + layout.addWidget(port_group) + + # Connection + conn_group = QGroupBox("Connection") + conn_layout = QVBoxLayout() + + self.connect_btn = QPushButton("Connect") + self.connect_btn.clicked.connect(self.on_connect_clicked) + conn_layout.addWidget(self.connect_btn) + + self.status_label = QLabel("Status: Disconnected") + self.status_label.setStyleSheet("color: #ff0000;") + conn_layout.addWidget(self.status_label) + + conn_group.setLayout(conn_layout) + layout.addWidget(conn_group) + + # Debug options + debug_group = QGroupBox("Debug Options") + debug_layout = QGridLayout() + + self.timeout_input = QLineEdit("1000") + self.retry_input = QLineEdit("3") + + debug_layout.addWidget(QLabel("Packet Timeout (ms):"), 0, 0) + debug_layout.addWidget(self.timeout_input, 0, 1) + debug_layout.addWidget(QLabel("Retry Count:"), 1, 0) + debug_layout.addWidget(self.retry_input, 1, 1) + + debug_group.setLayout(debug_layout) + layout.addWidget(debug_group) + + layout.addStretch() + self.setLayout(layout) + + def on_connect_clicked(self): + """Handle connect/disconnect button click.""" + if self.serial.is_connected: + self.serial.disconnect() + else: + port = self.port_combo.currentText() + baudrate = int(self.baudrate_combo.currentText()) + self.serial.connect(port, baudrate) + + def on_connected(self): + """Handle connection established.""" + self.connect_btn.setText("Disconnect") + self.status_label.setText("Status: Connected") + self.status_label.setStyleSheet("color: #00ff00;") + self.connection_changed.emit(True) + + def on_disconnected(self): + """Handle disconnection.""" + self.connect_btn.setText("Connect") + self.status_label.setText("Status: Disconnected") + self.status_label.setStyleSheet("color: #ff0000;") + self.connection_changed.emit(False) + + def on_error(self, message: str): + """Handle serial error.""" + QMessageBox.warning(self, "Serial Error", message) + +# ============================================================================ +# MAIN WINDOW +# ============================================================================ + +class LaserControlApp(QMainWindow): + """Main application window.""" + + def __init__(self): + super().__init__() + + # Initialize communication layers + self.serial_comm = SerialComm() + self.i2c_protocol = I2CProtocol(self.serial_comm) + self.laser_control = LaserControl(self.i2c_protocol) + + self.init_ui() + self.apply_stylesheet() + + # Connect signals + self.serial_comm.disconnected.connect(self.on_disconnected) + + def init_ui(self): + self.setWindowTitle("Genesis SLM MX 532 Laser Control") + self.setGeometry(100, 100, 800, 600) + + # Create central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + layout = QVBoxLayout() + central_widget.setLayout(layout) + + # Create tabs + self.tabs = QTabWidget() + + self.basic_tab = BasicControlTab(self.laser_control) + self.monitoring_tab = MonitoringTab(self.laser_control) + self.advanced_tab = AdvancedTab(self.i2c_protocol, self.serial_comm) + self.config_tab = ConfigTab(self.serial_comm) + + self.tabs.addTab(self.basic_tab, "Basic Controls") + self.tabs.addTab(self.monitoring_tab, "Monitoring") + self.tabs.addTab(self.advanced_tab, "Advanced") + self.tabs.addTab(self.config_tab, "Configuration") + + layout.addWidget(self.tabs) + + # Connect config tab signal + self.config_tab.connection_changed.connect(self.on_connection_changed) + + def on_connection_changed(self, connected: bool): + """Handle connection state change.""" + self.basic_tab.set_connection_status(connected) + if not connected: + self.on_disconnected() + + def on_disconnected(self): + """Handle disconnection - set safe state.""" + self.laser_control.safe_state() + self.basic_tab.reset_controls() + + def apply_stylesheet(self): + """Apply QSS stylesheet.""" + stylesheet = """ + QMainWindow { + background-color: #2b2b2b; + } + + QWidget { + background-color: #2b2b2b; + color: white; + } + + QPushButton { + background-color: #3c3c3c; + color: white; + border: 1px solid #555; + padding: 5px; + border-radius: 3px; + } + + QPushButton:hover { + background-color: #4c4c4c; + } + + QPushButton:pressed { + background-color: #2c2c2c; + } + + QPushButton#emergency { + background-color: #cc0000; + font-weight: bold; + font-size: 14pt; + min-height: 50px; + } + + QPushButton#emergency:hover { + background-color: #ff0000; + } + + QGroupBox { + border: 1px solid #555; + border-radius: 5px; + margin-top: 10px; + padding-top: 10px; + } + + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + } + + QSlider::groove:horizontal { + background: #3c3c3c; + height: 8px; + border-radius: 4px; + } + + QSlider::handle:horizontal { + background: #5c5c5c; + width: 16px; + margin: -4px 0; + border-radius: 8px; + } + + QSlider::handle:horizontal:hover { + background: #6c6c6c; + } + + QSpinBox, QLineEdit, QComboBox { + background-color: #3c3c3c; + border: 1px solid #555; + padding: 3px; + border-radius: 3px; + } + + QTextEdit { + background-color: #1c1c1c; + border: 1px solid #555; + border-radius: 3px; + } + + QTabWidget::pane { + border: 1px solid #555; + } + + QTabBar::tab { + background-color: #3c3c3c; + border: 1px solid #555; + padding: 8px 16px; + margin-right: 2px; + } + + QTabBar::tab:selected { + background-color: #4c4c4c; + } + + QTabBar::tab:hover { + background-color: #5c5c5c; + } + """ + self.setStyleSheet(stylesheet) + + def closeEvent(self, event): + """Handle window close event.""" + # Set safe state before closing + if self.serial_comm.is_connected: + self.laser_control.safe_state() + self.serial_comm.disconnect() + event.accept() + +# ============================================================================ +# MAIN +# ============================================================================ + +def main(): + app = QApplication(sys.argv) + window = LaserControlApp() + window.show() + sys.exit(app.exec()) + +if __name__ == '__main__': + main()