3433 lines
136 KiB
Python
3433 lines
136 KiB
Python
#!/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("<Not specified>")
|
|
|
|
# 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("<Invalid coordinates>")
|
|
self.lbl_scan_area.setText("<Invalid>")
|
|
else:
|
|
self.lbl_scan_coords.setText("<Not specified>")
|
|
self.lbl_scan_area.setText("<Not specified>")
|
|
|
|
# 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("<Invalid>")
|
|
else:
|
|
self.lbl_pixel_size.setText("<Not specified>")
|
|
|
|
# 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("<Not specified>")
|
|
|
|
# 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()
|