Files
scanengine-3/scanengine/app.py
T
2026-02-09 16:28:47 -06:00

3479 lines
138 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.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y, TriggerBitsServo
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."""
# Pause motion worker polling during scan to avoid conflicts
if self.motion_worker:
self.motion_worker.scanning_active = True
try:
self.scan_started.emit()
# Extract parameters
scan_boxes = self.scan_params.get('scan_boxes', [])
num_angles = len(scan_boxes)
row_spacing = self.scan_params.get('row_spacing', 0.1)
scan_velocity = self.scan_params.get('scan_velocity_mm_s', 200.0)
scan_accel = self.scan_params.get('scan_acceleration_mm_s2', 500.0)
print(f"Scan worker: Starting scan with {num_angles} angles")
print(f"Row spacing: {row_spacing} mm, velocity: {scan_velocity} mm/s")
controller = self.motion_worker.controller if self.motion_worker else None
if not controller:
self.scan_failed.emit("No motion controller connected")
return
# Store original velocity params for restoration
orig_x_velocity = controller.max_velocities[0]
orig_x_accel = controller.max_accels[0]
orig_y_velocity = controller.max_velocities[1]
orig_y_accel = controller.max_accels[1]
# Configure stage for high-speed scanning
controller.set_velocity_params(AXIS_X, max_velocity=scan_velocity, acceleration=scan_accel)
controller.set_velocity_params(AXIS_Y, max_velocity=scan_velocity, acceleration=scan_accel)
# Set X-axis trigger output HIGH during motion (for oscilloscope sync)
controller.set_trigger(AXIS_X, TriggerBitsServo.TRIGOUT_INMOTION)
self.status_message.emit("Scan configured, starting raster...")
# Main scan loop
for angle_idx, scan_box in enumerate(scan_boxes):
if self.should_stop:
self.scan_failed.emit("Scan aborted by user")
return
self.angle_started.emit(angle_idx, num_angles)
angle_deg = scan_box.get('angle_degrees', 0)
self.status_message.emit(f"Angle {angle_idx + 1}/{num_angles} ({angle_deg:.1f} deg)")
# Extract scan area boundaries
x_start, y_start = scan_box['start']
x_end, y_end = scan_box['end']
# Calculate scan lines
y_range = y_end - y_start
num_lines = max(1, int(y_range / row_spacing) + 1)
# Move to start position only for first angle
if angle_idx == 0:
self.status_message.emit("Moving to scan start position...")
controller.move_axis_absolute(AXIS_X, x_start, timeout=30.0)
controller.move_axis_absolute(AXIS_Y, y_start, timeout=30.0)
# Scan each line (snake/boustrophedon pattern)
for line_idx in range(num_lines):
if self.should_stop:
self.scan_failed.emit("Scan aborted by user")
return
y_current = y_start + line_idx * row_spacing
if y_current > y_end:
y_current = y_end
# Alternate scan direction for snake pattern
if line_idx % 2 == 0:
scan_start_x, scan_end_x = x_start, x_end
else:
scan_start_x, scan_end_x = x_end, x_start
self.line_started.emit(line_idx, num_lines, y_current)
# Position to line start (diagonal move - X and Y simultaneously)
# Y move issued first (non-blocking from stage perspective),
# then X move; both complete before scan line begins
controller.move_axis_absolute(AXIS_Y, y_current, timeout=20.0)
controller.move_axis_absolute(AXIS_X, scan_start_x, timeout=20.0)
# Perform scan line (trigger is HIGH during this move)
scan_distance = abs(scan_end_x - scan_start_x)
scan_timeout = max(10.0, scan_distance / scan_velocity * 3)
controller.move_axis_absolute(AXIS_X, scan_end_x, timeout=scan_timeout)
# Update progress
line_progress = int(100 * (line_idx + 1) / num_lines)
self.current_progress.emit(line_progress)
overall = int(100 * (angle_idx + (line_idx + 1) / num_lines) / num_angles)
self.overall_progress.emit(overall)
# Cleanup: disable triggers and restore original velocity
controller.set_trigger(AXIS_X, 0)
controller.set_velocity_params(AXIS_X, max_velocity=orig_x_velocity, acceleration=orig_x_accel)
controller.set_velocity_params(AXIS_Y, max_velocity=orig_y_velocity, acceleration=orig_y_accel)
self.status_message.emit("Scan complete")
self.scan_completed.emit()
except Exception as e:
print(f"ERROR in scan worker: {e}")
import traceback
traceback.print_exc()
# Best-effort cleanup on error
try:
if self.motion_worker and self.motion_worker.controller:
self.motion_worker.controller.set_trigger(AXIS_X, 0)
except Exception:
pass
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.am_homed[0]
y_homed = controller.am_homed[1]
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:
controller.home_axis(AXIS_X, timeout=60.0)
print("X-axis homed successfully")
self.current_scan_progress.setValue(50)
# Small delay to let system stabilize
import time
time.sleep(0.5)
except TimeoutError:
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:
controller.home_axis(AXIS_Y, timeout=60.0)
print("Y-axis homed successfully")
self.current_scan_progress.setValue(90)
# Small delay to let system stabilize
import time
time.sleep(0.5)
except TimeoutError:
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.pybbd202 import TriggerBitsServo
trigger_options = [
("Disabled", 0),
("Trigger In: Relative Move", TriggerBitsServo.TRIGIN_RELMOVE),
("Trigger In: Absolute Move", TriggerBitsServo.TRIGIN_ABSMOVE),
("Trigger In: Home", TriggerBitsServo.TRIGIN_HOMEMOVE),
("Trigger Out: In Motion", TriggerBitsServo.TRIGOUT_INMOTION),
("Trigger Out: Motion Complete", TriggerBitsServo.TRIGOUT_MOTIONCOMPLETE),
("Trigger Out: Max Velocity", TriggerBitsServo.TRIGOUT_MAXVELOCITY),
]
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()