#!/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("") # Scan Coordinates x_start = self.le_x_start_coord.text().strip() x_delta = self.le_x_delta_coord.text().strip() y_start = self.le_y_start_coord.text().strip() y_delta = self.le_y_delta_coord.text().strip() if all([x_start, x_delta, y_start, y_delta]): try: x_s = float(x_start) x_d = float(x_delta) y_s = float(y_start) y_d = float(y_delta) x_end = x_s + x_d y_end = y_s + y_d coord_text = f"X: {x_s:.2f}mm to {x_end:.2f}mm, Y: {y_s:.2f}mm to {y_end:.2f}mm" self.lbl_scan_coords.setText(coord_text) # Calculate scan area in mm² area_mm2 = abs(x_d * y_d) self.lbl_scan_area.setText(f"{area_mm2:.2f} mm²") except ValueError: self.lbl_scan_coords.setText("") self.lbl_scan_area.setText("") else: self.lbl_scan_coords.setText("") self.lbl_scan_area.setText("") # Row Spacing (Pixel Size) row_spacing = self.le_row_spacing.text().strip() if row_spacing: try: spacing = float(row_spacing) self.lbl_pixel_size.setText(f"{spacing:.3f} mm") except ValueError: self.lbl_pixel_size.setText("") else: self.lbl_pixel_size.setText("") # Data Directory / Save Location data_dir = self.le_data_dir.text().strip() waveform_prefix = self.le_waveform_prefix.text().strip() if data_dir and waveform_prefix: self.lbl_scan_save_location.setText(f"{data_dir}/{waveform_prefix}_*.wfm") elif data_dir: self.lbl_scan_save_location.setText(data_dir) else: self.lbl_scan_save_location.setText("") # Build additional summary information summary_parts = [] # Number of angles num_angles_idx = self.cb_number_of_angles.currentIndex() if num_angles_idx >= 0: num_angles_text = self.cb_number_of_angles.currentText() summary_parts.append(f"Angles: {num_angles_text}") # Scan type if self.rdo_standalone_mode.isChecked(): summary_parts.append("Type: Standalone") elif self.rdo_coop_mode.isChecked(): num_cycles = self.le_numcycles_coop.text().strip() if num_cycles: summary_parts.append(f"Type: Cooperative ({num_cycles} cycles)") else: summary_parts.append("Type: Cooperative") # Sample size sample_size_text = self.combo_sample_size.currentText() summary_parts.append(f"Sample: {sample_size_text}") # Display additional info in label_32 (currently empty label at row 10) if summary_parts: additional_info = " | ".join(summary_parts) print(f"Summary additional info: {additional_info}") # Create a label for scan parameters if it doesn't exist if not hasattr(self, 'lbl_scan_parameters'): self.lbl_scan_parameters = QtWidgets.QLabel() self.SummaryGridLayout.addWidget(QtWidgets.QLabel("Scan Parameters:"), 7, 0, QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter) self.SummaryGridLayout.addWidget(self.lbl_scan_parameters, 7, 1) self.lbl_scan_parameters.setText(additional_info) def on_start_scanning_clicked(self): """Handle start scanning button click""" print("Start scanning clicked!") # Save metadata before starting scan metadata_file = self.save_scan_metadata(scan_finished=False) if not metadata_file: QtWidgets.QMessageBox.critical( self, "Error", "Failed to save scan metadata. Cannot start scan." ) return print(f"Scan metadata saved to: {metadata_file}") # Load the metadata to get scan boxes try: with open(metadata_file, 'r') as f: metadata = json.load(f) except Exception as e: print(f"Error loading metadata: {e}") return # Get sample diameter sample_text = self.combo_sample_size.currentText() if "1.25" in sample_text: sample_diameter = 31.75 else: sample_diameter = 40.0 # Load scanning parameters from config scan_velocity = 50.0 # Default mm/s scan_acceleration_mm_s2 = 1500.0 # Default mm/s^2 try: config_file = os.path.join(os.path.dirname(__file__), '..', 'config.json') if os.path.exists(config_file): with open(config_file, 'r') as f: config = json.load(f) scan_velocity = float(config.get('scanning_stage', {}).get('scan_velocity_mm_s', '50.0')) scan_acceleration_mm_s2 = float(config.get('scanning_stage', {}).get('scan_acceleration_mm_s2', '1500.0')) print(f"Loaded from config: velocity={scan_velocity} mm/s, acceleration={scan_acceleration_mm_s2} mm/s^2") else: print(f"Config file not found at {config_file}, using defaults: velocity={scan_velocity} mm/s, accel={scan_acceleration_mm_s2} mm/s^2") except Exception as e: print(f"Error loading scan parameters from config: {e}, using defaults: velocity={scan_velocity} mm/s, accel={scan_acceleration_mm_s2} mm/s^2") # Prepare scan parameters for visualization scan_params = { 'num_angles': metadata['scan_parameters']['number_of_angles'], 'scan_boxes': metadata['scan_boxes'], 'x_start': metadata['scan_area']['x_start_mm'], 'x_delta': metadata['scan_area']['x_delta_mm'], 'y_start': metadata['scan_area']['y_start_mm'], 'y_delta': metadata['scan_area']['y_delta_mm'], 'row_spacing': metadata['scan_parameters']['row_spacing_mm'], 'sample_diameter_mm': sample_diameter, 'scan_velocity_mm_s': scan_velocity, 'scan_acceleration_mm_s2': scan_acceleration_mm_s2 # Already in mm/s^2 } # Show visualization dialog (pass motion worker for stage control) viz_dialog = ScanVisualizationDialog(self, scan_params, self.motion_worker) viz_dialog.exec() # Store metadata file path for later update self.current_scan_metadata_file = metadata_file # For now, just mark as finished when dialog closes # In real implementation, this would be called after actual scanning self.update_scan_metadata_finished() # Return to launcher self.return_to_launcher() def demo_scan_progress(self, progress_dialog): """ Demonstrate the scan progress dialog. This will be replaced with actual scanning logic. """ import time # Get scan parameters from the wizard try: num_angles = int(self.cb_number_of_angles.currentText()) except (ValueError, AttributeError): num_angles = 4 # Calculate number of rows (for demo purposes) try: y_delta = float(self.le_y_delta_coord.text()) row_spacing = float(self.le_row_spacing.text()) num_rows = max(1, int(abs(y_delta) / row_spacing)) except (ValueError, AttributeError): num_rows = 10 # Default for demo # Show the dialog progress_dialog.show() QtWidgets.QApplication.processEvents() # Simulate scanning scan_was_cancelled = False for scan_num in range(1, num_angles + 1): if progress_dialog.is_cancelled(): print("Scan cancelled by user") scan_was_cancelled = True break # Update total progress progress_dialog.update_total_progress(scan_num, num_angles) progress_dialog.reset_current_scan() # Simulate scanning rows for row_num in range(1, num_rows + 1): if progress_dialog.is_cancelled(): print("Scan cancelled by user") scan_was_cancelled = True break # Update current scan progress progress_dialog.update_current_scan(row_num, num_rows) # Process events to keep UI responsive QtWidgets.QApplication.processEvents() # Simulate scan time (remove this in real implementation) time.sleep(0.1) # Break outer loop if cancelled if scan_was_cancelled: break if scan_was_cancelled: # Close the progress dialog progress_dialog.close() # Don't update metadata file - scan was cancelled # Return to main launcher self.return_to_launcher() else: # Mark scan as complete progress_dialog.scan_complete() print("Scan completed successfully!") # Wait for user to close the dialog progress_dialog.exec() # Update metadata to mark scan as finished self.update_scan_metadata_finished() # After successful completion, also return to launcher self.return_to_launcher() def return_to_launcher(self): """Close the wizard and return to the main launcher""" print("Returning to main launcher") self.close() if self.parent_launcher: self.parent_launcher.show() def save_scan_metadata(self, scan_finished: bool = False) -> str: """ Save scan metadata to JSON file. Args: scan_finished: Whether the scan has been completed Returns: Path to the saved metadata file, or empty string on error """ try: from datetime import datetime import math # Get scan parameters x_start = float(self.le_x_start_coord.text().strip()) x_delta = float(self.le_x_delta_coord.text().strip()) y_start = float(self.le_y_start_coord.text().strip()) y_delta = float(self.le_y_delta_coord.text().strip()) num_angles = int(self.cb_number_of_angles.currentText()) # Calculate scan boxes for each angle with rotation scan_boxes = self.calculate_scan_boxes( x_start, x_delta, y_start, y_delta, num_angles ) # Collect metadata metadata = { "scan_info": { "friendly_name": self.le_scan_friendly_name.text().strip(), "waveform_prefix": self.le_waveform_prefix.text().strip(), "data_directory": self.le_data_dir.text().strip(), "timestamp": datetime.now().isoformat(), "scan_finished": scan_finished }, "scan_parameters": { "number_of_angles": num_angles, "row_spacing_mm": float(self.le_row_spacing.text().strip()), "scan_type": "standalone" if self.rdo_standalone_mode.isChecked() else "cooperative", }, "scan_area": { "x_start_mm": x_start, "x_delta_mm": x_delta, "y_start_mm": y_start, "y_delta_mm": y_delta, "sample_size": self.combo_sample_size.currentText() }, "scan_boxes": scan_boxes } # Add cooperative mode data if applicable if self.rdo_coop_mode.isChecked(): metadata["scan_parameters"]["num_cycles"] = int(self.le_numcycles_coop.text().strip()) # Generate filename: WFMPREFIX_TIME_DATE.json prefix = self.le_waveform_prefix.text().strip() timestamp = datetime.now() time_str = timestamp.strftime("%H%M%S") date_str = timestamp.strftime("%Y%m%d") filename = f"{prefix}_{time_str}_{date_str}.json" # Full path data_dir = self.le_data_dir.text().strip() filepath = os.path.join(data_dir, filename) # Save to file with open(filepath, 'w') as f: json.dump(metadata, f, indent=2) return filepath except Exception as e: print(f"Error saving scan metadata: {e}") import traceback traceback.print_exc() return "" def calculate_scan_boxes(self, x_start: float, x_delta: float, y_start: float, y_delta: float, num_angles: int) -> list: """ Calculate scan boxes for each angle with CCW rotation. Args: x_start: Starting X coordinate (mm) x_delta: X extent (mm) y_start: Starting Y coordinate (mm) y_delta: Y extent (mm) num_angles: Number of scan angles Returns: List of scan box dictionaries with rotated coordinates """ import math # Calculate the bounding box corners x_end = x_start + x_delta y_end = y_start + y_delta # Original bounding box (before rotation) original_box = { "start": [x_start, y_start], "end": [x_end, y_end] } # Center of rotation (optical axis) cx = self.optical_axis_x cy = self.optical_axis_y scan_boxes = [] for angle_idx in range(num_angles): # Calculate rotation angle in radians (CCW) # Use 180° instead of 360° since data is symmetric angle_deg = (180.0 / num_angles) * angle_idx angle_rad = math.radians(angle_deg) # Rotate all four corners of the bounding box corners = [ (x_start, y_start), # Bottom-left (x_end, y_start), # Bottom-right (x_end, y_end), # Top-right (x_start, y_end) # Top-left ] rotated_corners = [] for x, y in corners: # Rotate around optical axis center (CCW) x_rot = cx + (x - cx) * math.cos(angle_rad) - (y - cy) * math.sin(angle_rad) y_rot = cy + (x - cx) * math.sin(angle_rad) + (y - cy) * math.cos(angle_rad) rotated_corners.append((x_rot, y_rot)) # Find the new bounding box that encompasses all rotated corners x_coords = [c[0] for c in rotated_corners] y_coords = [c[1] for c in rotated_corners] rotated_x_start = min(x_coords) rotated_x_end = max(x_coords) rotated_y_start = min(y_coords) rotated_y_end = max(y_coords) scan_box = { "angle_index": angle_idx, "angle_degrees": angle_deg, "start": [round(rotated_x_start, 4), round(rotated_y_start, 4)], "end": [round(rotated_x_end, 4), round(rotated_y_end, 4)], "original_corners": [ [round(c[0], 4), round(c[1], 4)] for c in rotated_corners ] } scan_boxes.append(scan_box) return scan_boxes def update_scan_metadata_finished(self): """Update the scan metadata file to mark scan as finished""" if not hasattr(self, 'current_scan_metadata_file') or not self.current_scan_metadata_file: print("Warning: No metadata file to update") return try: # Read existing metadata with open(self.current_scan_metadata_file, 'r') as f: metadata = json.load(f) # Update scan_finished flag metadata["scan_info"]["scan_finished"] = True # Add completion timestamp from datetime import datetime metadata["scan_info"]["completion_timestamp"] = datetime.now().isoformat() # Write back to file with open(self.current_scan_metadata_file, 'w') as f: json.dump(metadata, f, indent=2) print(f"Updated scan metadata: scan_finished = True") except Exception as e: print(f"Error updating scan metadata: {e}") import traceback traceback.print_exc() class ScanVisualizationDialog(QtWidgets.QDialog): """Dialog showing scan visualization with rotation animation""" def __init__(self, parent=None, scan_params=None, motion_worker=None): super().__init__(parent) self.setWindowTitle("Scan Visualization") self.setModal(False) self.setMinimumWidth(800) self.setMinimumHeight(700) # Store scan parameters self.scan_params = scan_params or {} self.motion_worker = motion_worker self.current_angle_index = 0 self.pixels_per_mm = 6.0 self.optical_axis_x = 55.0 self.optical_axis_y = 35.0 # Store scan visualization items for easy removal self.scan_items = [] # Position indicator (black dot showing current stage position) self.position_indicator = None # Scan worker and thread self.scan_worker = None self.scan_thread = None # Create layout main_layout = QtWidgets.QVBoxLayout() # Info label self.info_label = QtWidgets.QLabel("Scan Visualization - Click 'Next Angle' to rotate") self.info_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) font = self.info_label.font() font.setPointSize(12) font.setBold(True) self.info_label.setFont(font) main_layout.addWidget(self.info_label) # Angle display self.angle_label = QtWidgets.QLabel("Angle: 0° (Scan 1 of 1)") self.angle_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) angle_font = self.angle_label.font() angle_font.setPointSize(11) self.angle_label.setFont(angle_font) main_layout.addWidget(self.angle_label) # Graphics view self.scene = QtWidgets.QGraphicsScene() self.graphics_view = QtWidgets.QGraphicsView(self.scene) self.graphics_view.setMinimumSize(600, 500) main_layout.addWidget(self.graphics_view) # Progress bars progress_layout = QtWidgets.QVBoxLayout() # Current scan progress self.current_scan_label = QtWidgets.QLabel("Current Scan: Waiting to start...") progress_layout.addWidget(self.current_scan_label) self.current_scan_progress = QtWidgets.QProgressBar() self.current_scan_progress.setRange(0, 100) self.current_scan_progress.setValue(0) progress_layout.addWidget(self.current_scan_progress) # Overall progress self.overall_progress_label = QtWidgets.QLabel("Overall Progress: 0 of 0 scans") progress_layout.addWidget(self.overall_progress_label) self.overall_progress = QtWidgets.QProgressBar() self.overall_progress.setRange(0, 100) self.overall_progress.setValue(0) progress_layout.addWidget(self.overall_progress) main_layout.addLayout(progress_layout) # Button layout button_layout = QtWidgets.QHBoxLayout() self.btn_prev = QtWidgets.QPushButton("← Previous Angle") self.btn_prev.clicked.connect(self.on_prev_angle) self.btn_prev.setEnabled(False) button_layout.addWidget(self.btn_prev) self.btn_next = QtWidgets.QPushButton("Next Angle →") self.btn_next.clicked.connect(self.on_next_angle) button_layout.addWidget(self.btn_next) button_layout.addStretch() self.btn_start_scan = QtWidgets.QPushButton("Start Scan") self.btn_start_scan.clicked.connect(self.on_start_scan) button_layout.addWidget(self.btn_start_scan) self.btn_close = QtWidgets.QPushButton("Close") self.btn_close.clicked.connect(self.accept) button_layout.addWidget(self.btn_close) main_layout.addLayout(button_layout) # Scanning state self.is_scanning = False self.scan_state = { 'angle_idx': 0, 'line_idx': 0, 'scan_lines': [], 'current_scan_box': None } self.setLayout(main_layout) # Initialize the visualization self.setup_scene() self.draw_current_angle() # Auto-start scanning after a short delay QtCore.QTimer.singleShot(500, self.on_start_scan) def setup_scene(self): """Set up the graphics scene with static elements""" # Set scene size diameter_mm = 50.0 scene_size = diameter_mm * self.pixels_per_mm self.scene.setSceneRect(-scene_size/2, -scene_size/2, scene_size, scene_size) # Draw the 50mm diameter circle (working area boundary) pen = QtGui.QPen(QtGui.QColor(100, 100, 100)) pen.setWidth(2) radius = scene_size / 2 self.scene.addEllipse(-radius, -radius, 2*radius, 2*radius, pen) # Draw crosshair at center (optical axis) crosshair_pen = QtGui.QPen(QtGui.QColor(0, 0, 0)) crosshair_pen.setWidth(1) crosshair_size = 15 self.scene.addLine(-crosshair_size, 0, crosshair_size, 0, crosshair_pen) self.scene.addLine(0, -crosshair_size, 0, crosshair_size, crosshair_pen) # Position indicator (black dot, 3px diameter) position_pen = QtGui.QPen(QtGui.QColor(0, 0, 0)) position_brush = QtGui.QBrush(QtGui.QColor(0, 0, 0)) self.position_indicator = self.scene.addEllipse(-1.5, -1.5, 3, 3, position_pen, position_brush) self.position_indicator.setZValue(100) # Always on top # Connect to motion worker position updates if self.motion_worker: self.motion_worker.position_updated.connect(self.update_position_indicator) # Sample circle will be drawn in draw_current_angle (it rotates) def draw_current_angle(self): """Draw the rotated sample and fixed-direction scan lines""" # Remove previous scan visualization items for item in self.scan_items: self.scene.removeItem(item) self.scan_items.clear() num_angles = self.scan_params.get('num_angles', 1) if num_angles == 0: return # Get scan boxes scan_boxes = self.scan_params.get('scan_boxes', []) if self.current_angle_index >= len(scan_boxes): return scan_box = scan_boxes[self.current_angle_index] angle_deg = scan_box['angle_degrees'] # Update label self.angle_label.setText( f"Angle: {angle_deg:.1f}° (Scan {self.current_angle_index + 1} of {num_angles})" ) # Draw the ROTATED sample in red (original scan area rotates with sample) self.draw_rotated_sample(angle_deg) # Draw the FIXED scan area (bounding box of rotated sample) # Scan always happens in X+ direction, stepping in Y+ self.draw_fixed_scan_area(scan_box) def draw_rotated_sample(self, angle_deg): """Draw the original scan area rotated with the sample""" import math # Get original scan parameters x_start = self.scan_params.get('x_start', 0) x_delta = self.scan_params.get('x_delta', 0) y_start = self.scan_params.get('y_start', 0) y_delta = self.scan_params.get('y_delta', 0) angle_rad = math.radians(angle_deg) cx = self.optical_axis_x cy = self.optical_axis_y # Define original scan area corners x_end = x_start + x_delta y_end = y_start + y_delta corners = [ (x_start, y_start), (x_end, y_start), (x_end, y_end), (x_start, y_end) ] # Rotate corners polygon = QtGui.QPolygonF() for x, y in corners: x_rot = cx + (x - cx) * math.cos(angle_rad) - (y - cy) * math.sin(angle_rad) y_rot = cy + (x - cx) * math.sin(angle_rad) + (y - cy) * math.cos(angle_rad) # Convert to scene coordinates (standard Cartesian: +X right, +Y up) x_scene = (x_rot - self.optical_axis_x) * self.pixels_per_mm y_scene = (y_rot - self.optical_axis_y) * self.pixels_per_mm polygon.append(QtCore.QPointF(x_scene, -y_scene)) # Negate for Qt's Y-down # Draw rotated sample area in red sample_pen = QtGui.QPen(QtGui.QColor(255, 0, 0)) # Red sample_pen.setWidth(2) sample_brush = QtGui.QBrush(QtGui.QColor(255, 0, 0, 30)) # Semi-transparent red polygon_item = self.scene.addPolygon(polygon, sample_pen, sample_brush) polygon_item.setZValue(5) self.scan_items.append(polygon_item) def draw_fixed_scan_area(self, scan_box): """Draw the bounding box and scan lines (always X+, stepping Y+)""" # Get the bounding box (axis-aligned, non-rotated) x_start_stage = scan_box['start'][0] y_start_stage = scan_box['start'][1] x_end_stage = scan_box['end'][0] y_end_stage = scan_box['end'][1] print(f" Bounding box (stage): X=[{x_start_stage:.2f}, {x_end_stage:.2f}], Y=[{y_start_stage:.2f}, {y_end_stage:.2f}]") # Convert to scene coordinates (standard Cartesian: +X right, +Y up) x_start_scene = (x_start_stage - self.optical_axis_x) * self.pixels_per_mm y_start_scene = (y_start_stage - self.optical_axis_y) * self.pixels_per_mm x_end_scene = (x_end_stage - self.optical_axis_x) * self.pixels_per_mm y_end_scene = (y_end_stage - self.optical_axis_y) * self.pixels_per_mm # Find actual min/max x_min_scene = min(x_start_scene, x_end_scene) x_max_scene = max(x_start_scene, x_end_scene) y_min_scene = min(y_start_scene, y_end_scene) y_max_scene = max(y_start_scene, y_end_scene) width_scene = x_max_scene - x_min_scene height_scene = y_max_scene - y_min_scene print(f" Bounding box (scene): X=[{x_min_scene:.1f}, {x_max_scene:.1f}], Y=[{y_min_scene:.1f}, {y_max_scene:.1f}]") print(f" Width={width_scene:.1f}, Height={height_scene:.1f}") # Draw bounding box in brown (negate Y for Qt's coordinate system) scan_area_pen = QtGui.QPen(QtGui.QColor(139, 69, 19)) # Brown scan_area_pen.setWidth(3) scan_area_brush = QtGui.QBrush(QtGui.QColor(139, 69, 19, 50)) rect_item = self.scene.addRect(x_min_scene, -y_max_scene, width_scene, height_scene, scan_area_pen, scan_area_brush) rect_item.setZValue(10) self.scan_items.append(rect_item) # Draw scan lines (X+ direction, stepping in Y+) row_spacing = self.scan_params.get('row_spacing', 1.0) row_spacing_scene = row_spacing * self.pixels_per_mm scan_line_pen = QtGui.QPen(QtGui.QColor(0, 100, 200)) # Blue scan_line_pen.setWidth(1) # Generate horizontal lines stepping in Y num_lines = int(height_scene / row_spacing_scene) + 1 print(f" Drawing {num_lines} scan lines") for i in range(num_lines + 1): y_current = y_min_scene + i * row_spacing_scene if y_current > y_max_scene: break # Negate Y for Qt's coordinate system line_item = self.scene.addLine(x_min_scene, -y_current, x_max_scene, -y_current, scan_line_pen) line_item.setZValue(11) self.scan_items.append(line_item) def on_next_angle(self): """Show the next angle""" num_angles = self.scan_params.get('num_angles', 1) if self.current_angle_index < num_angles - 1: self.current_angle_index += 1 self.draw_current_angle() self.btn_prev.setEnabled(True) if self.current_angle_index >= num_angles - 1: self.btn_next.setEnabled(False) def on_prev_angle(self): """Show the previous angle""" if self.current_angle_index > 0: self.current_angle_index -= 1 self.draw_current_angle() self.btn_next.setEnabled(True) if self.current_angle_index == 0: self.btn_prev.setEnabled(False) def update_position_indicator(self, x: float, y: float): """Update the position indicator dot to show current stage position""" if not self.position_indicator: return # Convert stage coordinates to scene coordinates x_scene = (x - self.optical_axis_x) * self.pixels_per_mm y_scene = (y - self.optical_axis_y) * self.pixels_per_mm # Position the indicator (center at position, negate Y for Qt coordinates) self.position_indicator.setPos(x_scene, -y_scene) def on_start_scan(self): """Start the scanning process""" if self.is_scanning: return self.is_scanning = True # Disable navigation buttons during scan self.btn_prev.setEnabled(False) self.btn_next.setEnabled(False) self.btn_start_scan.setEnabled(False) # Update info label self.info_label.setText("Scanning in Progress...") # Check motion controller and home first QtCore.QTimer.singleShot(100, self.execute_scan) def execute_scan(self): """Initialize and start the scanning process in a worker thread""" scan_boxes = self.scan_params.get('scan_boxes', []) num_angles = len(scan_boxes) print(f"Starting scan with {num_angles} angles") # Check motion controller connection and home status if not self.check_motion_controller_ready(): self.current_scan_label.setText("ERROR: Motion controller not ready") self.info_label.setText("Scan Failed - Check motion controller") self.btn_close.setEnabled(True) self.is_scanning = False return # Pre-flight checks passed - ready to scan print("\n=== Pre-flight checks complete - starting scan ===\n") self.current_scan_label.setText("Ready - Starting scan...") self.info_label.setText("Scanning in Progress...") # Reset progress bars self.current_scan_progress.setValue(0) self.overall_progress.setValue(0) # Create scan worker and thread self.scan_worker = ScanWorker(self.scan_params, self.motion_worker) self.scan_thread = QtCore.QThread() self.scan_worker.moveToThread(self.scan_thread) # Connect signals self.scan_worker.scan_started.connect(self.on_scan_started) self.scan_worker.scan_completed.connect(self.on_scan_completed) self.scan_worker.scan_failed.connect(self.on_scan_failed) self.scan_worker.angle_started.connect(self.on_angle_started) self.scan_worker.line_started.connect(self.on_line_started) self.scan_worker.current_progress.connect(self.current_scan_progress.setValue) self.scan_worker.overall_progress.connect(self.overall_progress.setValue) self.scan_worker.status_message.connect(self.current_scan_label.setText) # Connect thread lifecycle self.scan_thread.started.connect(self.scan_worker.run_scan) self.scan_thread.finished.connect(self.scan_thread.deleteLater) # Start the thread self.scan_thread.start() def on_scan_started(self): """Handle scan started signal""" print("Scan started in worker thread") def on_scan_completed(self): """Handle scan completed signal""" self.overall_progress_label.setText(f"Overall Progress: Complete") self.current_scan_label.setText("Scan Complete!") self.info_label.setText("Scan Complete - Click Close to finish") self.btn_close.setEnabled(True) self.is_scanning = False self.cleanup_scan_thread() def on_scan_failed(self, error_msg): """Handle scan failed signal""" self.current_scan_label.setText(f"Scan Failed: {error_msg}") self.info_label.setText("Scan Failed") self.btn_close.setEnabled(True) self.is_scanning = False self.cleanup_scan_thread() def on_angle_started(self, angle_idx, total_angles): """Handle angle started signal""" self.current_angle_index = angle_idx self.draw_current_angle() self.overall_progress_label.setText( f"Overall Progress: Scan {angle_idx + 1} of {total_angles}" ) def on_line_started(self, line_idx, total_lines, y_position): """Handle line started signal""" self.current_scan_label.setText( f"Scanning Row {line_idx + 1} of {total_lines} at Y={y_position:.2f}mm" ) def cleanup_scan_thread(self): """Clean up the scan thread""" if self.scan_thread and self.scan_thread.isRunning(): self.scan_thread.quit() self.scan_thread.wait() self.scan_thread = None self.scan_worker = None def check_motion_controller_ready(self) -> bool: """ Check if motion controller is connected and homed. If not connected, connect. If not homed, home axes. Returns: True if ready for scanning, False otherwise """ if not self.motion_worker: print("ERROR: No motion worker available") QtWidgets.QMessageBox.critical( self, "Motion Controller Error", "Motion worker is not available. Cannot proceed with scan." ) return False # Check if connected if not self.motion_worker.is_connected: print("Motion controller not connected - attempting to connect...") self.current_scan_label.setText("Connecting to motion controller...") # Try to connect self.motion_worker.queue_connect() # Wait up to 10 seconds for connection import time for i in range(100): # 100 * 100ms = 10 seconds QtWidgets.QApplication.processEvents() if self.motion_worker.is_connected: print("Motion controller connected successfully") break time.sleep(0.1) if not self.motion_worker.is_connected: print("ERROR: Failed to connect to motion controller") QtWidgets.QMessageBox.critical( self, "Connection Error", "Failed to connect to motion controller.\n\n" "Please check:\n" "• Stage controller is powered on\n" "• USB connection is secure\n" "• No other software is using the controller" ) return False # Check home status controller = self.motion_worker.controller if not controller: print("ERROR: Controller object not available") return False print("Checking home status...") self.current_scan_label.setText("Checking home status...") # Get home status for both axes x_homed = controller.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()