From 66e72884febb9b55ec42dddd35de9aa9156ae301 Mon Sep 17 00:00:00 2001 From: "Thomas Ales [M S E]" Date: Thu, 9 Jul 2026 09:27:06 -0500 Subject: [PATCH] pre local llm checkpoint --- left_off.md | 55 - scanengine/__init__.py | 2 - scanengine/app.py | 3478 -------------------------------- scanengine/jog_stage_dialog.ui | 270 --- scanengine/main_launcher.ui | 109 - scanengine/motion_worker.py | 395 ---- scanengine/new_scan_wizard.ui | 1113 ---------- scanengine/options.ui | 743 ------- 8 files changed, 6165 deletions(-) delete mode 100644 left_off.md delete mode 100644 scanengine/__init__.py delete mode 100644 scanengine/app.py delete mode 100644 scanengine/jog_stage_dialog.ui delete mode 100644 scanengine/main_launcher.ui delete mode 100644 scanengine/motion_worker.py delete mode 100644 scanengine/new_scan_wizard.ui delete mode 100644 scanengine/options.ui diff --git a/left_off.md b/left_off.md deleted file mode 100644 index 0b31d73..0000000 --- a/left_off.md +++ /dev/null @@ -1,55 +0,0 @@ -# Session summary — scan timeout & geometry fixes - -## Bug fixed: oscilloscope timeout on first row - -**File:** `hardware/tektronix_base.py` — `transfer_fastframe()` (~line 1414) - -The scope was configured for N frames but only triggered on fewer (e.g. 57 of 58) because -the stage decelerates before the last laser pulse. `transfer_fastframe` looped using -`get_fastframe_count()` (the configured maximum), so the final `read_raw()` call blocked -waiting for data that never came and timed out. - -**Fix:** replaced `get_fastframe_count()` with a live query: -```python -acquired = int(self.query("ACQuire:NUMFRAMESACQuired?")) -``` -The loop now reads exactly as many frames as the scope actually captured. - ---- - -## Scan geometry overhaul - -**File:** `sc3_aui_app.py` - -### Constants added (near line 71) -```python -LASER_FREQ_HZ = 2000.0 # fixed laser pulse frequency -SCAN_RAMP_MM = v² / (2a) # ≈ 3.33 mm (100² / 2×1500) -``` -`SCAN_RAMP_MM` is the distance the stage needs to accelerate from rest to full scan -velocity, or decelerate back to rest. - -### `_run_scan()` changes -- `points_per_row` now uses `LASER_FREQ_HZ` instead of the param-supplied `laser_freq`. -- Pre-scan X position is `x_start - SCAN_RAMP_MM` so the stage arrives at `x_start` - already at full velocity (TRIGOUT_MAXV fires at the right place). -- Scan move ends at `x_start + x_delta + SCAN_RAMP_MM` so the stage doesn't begin - decelerating until after the last data point. - -### Travel-limit guards added (before any hardware interaction) -Raises `ValueError` with an actionable message if the extended move would exceed the -stage limits baked into the driver (`bbd20x.py`: X 0–110 mm, Y 0–75 mm): -- `x_start - SCAN_RAMP_MM < 0` -- `x_start + x_delta + SCAN_RAMP_MM > 110` -- any Y row position outside 0–75 mm - -Error messages tell the user exactly how many mm to adjust. - ---- - -## What to check / next steps -- Verify `ACQuire:NUMFRAMESACQuired?` is the correct query string for the specific scope - model in use (MDO/MSO series assumed; confirm against programmer manual). -- `LASER_FREQ_HZ = 2000.0` is a temporary constant — wire it to the UI param when ready. -- Confirm `SCAN_RAMP_MM` matches observed stage behaviour; if the BBD202 velocity profile - is not perfectly triangular the empirical ramp may differ slightly from v²/2a. diff --git a/scanengine/__init__.py b/scanengine/__init__.py deleted file mode 100644 index fe89eb4..0000000 --- a/scanengine/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""ScanEngine-3 main application package""" -__version__ = "3.0.0" diff --git a/scanengine/app.py b/scanengine/app.py deleted file mode 100644 index 1414fe2..0000000 --- a/scanengine/app.py +++ /dev/null @@ -1,3478 +0,0 @@ -#!/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() diff --git a/scanengine/jog_stage_dialog.ui b/scanengine/jog_stage_dialog.ui deleted file mode 100644 index 91bffa9..0000000 --- a/scanengine/jog_stage_dialog.ui +++ /dev/null @@ -1,270 +0,0 @@ - - - JogStageDialog - - - - 0 - 0 - 400 - 400 - - - - Jog Stage - - - - - - - 16 - 75 - true - - - - Stage Jogging Control - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - 12 - - - - Position: X=0.00mm, Y=0.00mm - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Qt::Orientation::Vertical - - - - 20 - 20 - - - - - - - - - - - 80 - 60 - - - - - 14 - 75 - true - - - - +Y - - - - - - - - 80 - 60 - - - - - 14 - 75 - true - - - - +X - - - - - - - - - Jog Speed (mm/s): - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - 100 - 16777215 - - - - Qt::AlignmentFlag::AlignCenter - - - 20.0 - - - - - - - Step Size (mm): - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - 100 - 16777215 - - - - Qt::AlignmentFlag::AlignCenter - - - 1.0 - - - - - - - - - - 80 - 60 - - - - - 14 - 75 - true - - - - -X - - - - - - - - 80 - 60 - - - - - 14 - 75 - true - - - - -Y - - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 20 - - - - - - - - Status: Disconnected - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - Connect - - - - - - - Home All Axes - - - false - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - Close - - - - - - - - - - diff --git a/scanengine/main_launcher.ui b/scanengine/main_launcher.ui deleted file mode 100644 index 3dfa39f..0000000 --- a/scanengine/main_launcher.ui +++ /dev/null @@ -1,109 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 653 - 360 - - - - - Bahnschrift - - - - Scanengin3 | v3.1.0 | build: nottagain - - - - - - - - - - Bahnschrift - 26 - - - - Scanengine Task Launcher: - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - Bahnschrift - 14 - - - - Choose a workflow: - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Begin a New Scan - - - - - - - Continue an Existing Scan - - - - - - - Configure System / Set Default Values - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - - - - diff --git a/scanengine/motion_worker.py b/scanengine/motion_worker.py deleted file mode 100644 index e2f523d..0000000 --- a/scanengine/motion_worker.py +++ /dev/null @@ -1,395 +0,0 @@ -""" -Motion Controller Worker Thread - -Handles all motion control operations in a separate thread to keep the UI responsive. -Provides async command queueing and position updates via Qt signals. -""" - -from PyQt6 import QtCore -from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y -import queue -import time -from typing import Optional, Dict, Any - - -class MotionCommand: - """Represents a motion command""" - def __init__(self, cmd_type: str, **kwargs): - self.cmd_type = cmd_type - self.params = kwargs - - -class MotionWorker(QtCore.QObject): - """ - Worker object for handling motion control in a separate thread. - - Signals: - connected: Emitted when controller connects successfully - disconnected: Emitted when controller disconnects - connection_failed: Emitted when connection fails (error_msg: str) - position_updated: Emitted when position changes (x: float, y: float) - homed_status: Emitted with home status (x_homed: bool, y_homed: bool) - move_completed: Emitted when a move completes (axis: str) - error_occurred: Emitted when an error occurs (error_msg: str) - """ - - # Signals - connected = QtCore.pyqtSignal() - disconnected = QtCore.pyqtSignal() - connection_failed = QtCore.pyqtSignal(str) - position_updated = QtCore.pyqtSignal(float, float) # x, y in mm - homed_status = QtCore.pyqtSignal(bool, bool) # x_homed, y_homed - motion_status = QtCore.pyqtSignal(bool, bool) # x_moving, y_moving - move_completed = QtCore.pyqtSignal(str) # axis name - error_occurred = QtCore.pyqtSignal(str) # error message - - def __init__(self): - super().__init__() - self.controller: Optional[ThorlabsServoDriver] = None - self.is_connected = False - self.command_queue = queue.Queue() - self.running = True - - # Default parameters - self.jog_speed = 20.0 # mm/s - self.acceleration = 50.0 # mm/s^2 - self.step_size = 1.0 # mm - - # Position tracking - self.last_x = None - self.last_y = None - - # Status tracking - self.last_x_homed = None - self.last_y_homed = None - self.last_x_moving = None - self.last_y_moving = None - - # Position update throttling - self.last_position_update_time = 0 - self.position_update_interval = 0.2 # seconds between position reads - - # Flag to pause polling during scanning (scan worker handles its own position queries) - self.scanning_active = False - - @QtCore.pyqtSlot() - def run(self): - """Main worker loop - processes commands from queue""" - print("Motion worker thread started") - - while self.running: - try: - # Check for commands with timeout to allow periodic position updates - try: - cmd = self.command_queue.get(timeout=0.05) # 50ms timeout - self.process_command(cmd) - except queue.Empty: - pass - - # Periodically update position and status if connected - # Skip updates during scanning - scan worker handles its own position queries - if self.is_connected and self.controller and not self.scanning_active: - self.update_position() - self.update_home_status() - self.update_motion_status() - - except Exception as e: - print(f"Error in motion worker loop: {e}") - self.error_occurred.emit(str(e)) - - # Cleanup on exit - if self.controller: - try: - self.controller.disconnect() - except: - pass - - print("Motion worker thread stopped") - - def process_command(self, cmd: MotionCommand): - """Process a motion command""" - try: - if cmd.cmd_type == 'connect': - self.do_connect() - elif cmd.cmd_type == 'disconnect': - self.do_disconnect() - elif cmd.cmd_type == 'jog': - self.do_jog(cmd.params['axis'], cmd.params['direction']) - elif cmd.cmd_type == 'home': - self.do_home(cmd.params['axis']) - elif cmd.cmd_type == 'set_velocity': - self.do_set_velocity(cmd.params['speed'], cmd.params['accel']) - elif cmd.cmd_type == 'set_step_size': - self.step_size = cmd.params['step_size'] - elif cmd.cmd_type == 'set_axis_enable': - self.do_set_axis_enable(cmd.params['axis'], cmd.params['enabled']) - elif cmd.cmd_type == 'stop': - self.running = False - - except Exception as e: - print(f"Error processing command {cmd.cmd_type}: {e}") - self.error_occurred.emit(f"Command '{cmd.cmd_type}' failed: {str(e)}") - - def do_connect(self): - """Connect to the motion controller""" - try: - self.controller = ThorlabsServoDriver() - self.controller.connect() - - # Enable channels - self.controller.enable_axis(AXIS_X) - self.controller.enable_axis(AXIS_Y) - - # Start polling to populate cached state (positions, homed, moving, errors) - self.controller.start_polling(interval=0.2) - - # Wait for first polling cycle to populate status - time.sleep(0.3) - - # Set initial velocity parameters - for dest in [AXIS_X, AXIS_Y]: - self.controller.set_velocity_params( - dest, - max_velocity=self.jog_speed, - acceleration=self.acceleration - ) - - self.is_connected = True - # Force initial updates (they will be emitted because last values are None) - self.update_position() - self.update_home_status() - self.update_motion_status() - self.connected.emit() - - print("Motion controller connected successfully") - - except Exception as e: - print(f"Failed to connect to motion controller: {e}") - self.connection_failed.emit(str(e)) - - def do_disconnect(self): - """Disconnect from the motion controller""" - if self.controller: - try: - self.controller.disconnect() - print("Motion controller disconnected") - except Exception as e: - print(f"Error during disconnect: {e}") - - self.controller = None - self.is_connected = False - self.disconnected.emit() - - def do_jog(self, axis: str, direction: int): - """Execute a jog move""" - if not self.is_connected or not self.controller: - return - - try: - dest = AXIS_X if axis == 'x' else AXIS_Y - - # Calculate relative distance - distance = self.step_size * direction - - # Execute the move (blocking, with short timeout for continuous jogging) - self.controller.move_axis_relative(dest, distance, timeout=0.5) - - # Update position - self.update_position() - - self.move_completed.emit(axis) - - except TimeoutError: - # Timeout is expected during continuous jog - don't report as error - pass - except Exception as e: - print(f"Jog error: {e}") - self.error_occurred.emit(f"Jog failed: {str(e)}") - - def do_home(self, axis: str): - """Home an axis""" - if not self.is_connected or not self.controller: - return - - try: - dest = AXIS_X if axis == 'x' else AXIS_Y - - print(f"Homing {axis.upper()} axis...") - self.controller.home_axis(dest, timeout=20.0) - - # Update position and status after homing - self.update_position() - self.update_home_status() - - print(f"{axis.upper()} axis homed successfully") - - except TimeoutError: - print(f"Home timeout: {axis.upper()} axis") - self.error_occurred.emit(f"Homing {axis.upper()} timed out") - except Exception as e: - print(f"Home error: {e}") - self.error_occurred.emit(f"Homing {axis.upper()} failed: {str(e)}") - - def do_set_velocity(self, speed: float, accel: float): - """Set velocity parameters""" - if not self.is_connected or not self.controller: - self.jog_speed = speed - self.acceleration = accel - return - - try: - self.jog_speed = speed - self.acceleration = accel - - for dest in [AXIS_X, AXIS_Y]: - self.controller.set_velocity_params( - dest, - max_velocity=self.jog_speed, - acceleration=self.acceleration - ) - - except Exception as e: - print(f"Set velocity error: {e}") - - def do_set_axis_enable(self, axis: str, enabled: bool): - """Enable or disable an axis for manual movement""" - if not self.is_connected or not self.controller: - return - - try: - dest = AXIS_X if axis == 'x' else AXIS_Y - if enabled: - self.controller.enable_axis(dest) - else: - self.controller.disable_axis(dest) - state_str = "enabled" if enabled else "disabled" - print(f"{axis.upper()} axis {state_str}") - - except Exception as e: - print(f"Set axis enable error: {e}") - self.error_occurred.emit(f"Failed to {'enable' if enabled else 'disable'} {axis.upper()} axis: {str(e)}") - - def update_position(self): - """Update current position and emit signal if changed""" - if not self.is_connected or not self.controller: - return - - # Throttle position reads to avoid excessive signal emission - current_time = time.time() - if current_time - self.last_position_update_time < self.position_update_interval: - return - self.last_position_update_time = current_time - - try: - # Read cached positions (populated by polling worker) - x_pos = self.controller.positions[0] - y_pos = self.controller.positions[1] - - # Always emit on first update, or if position changed significantly (> 0.001mm) - if (self.last_x is None or self.last_y is None or - abs(x_pos - self.last_x) > 0.001 or abs(y_pos - self.last_y) > 0.001): - self.last_x = x_pos - self.last_y = y_pos - print(f"Position update: X={x_pos:.3f}mm, Y={y_pos:.3f}mm") - self.position_updated.emit(x_pos, y_pos) - - except Exception as e: - print(f"Error updating position: {e}") - import traceback - traceback.print_exc() - - def update_home_status(self): - """Update home status and emit signal if changed""" - if not self.is_connected or not self.controller: - return - - try: - x_homed = self.controller.am_homed[0] - y_homed = self.controller.am_homed[1] - - # Only emit if status changed - if x_homed != self.last_x_homed or y_homed != self.last_y_homed: - self.last_x_homed = x_homed - self.last_y_homed = y_homed - self.homed_status.emit(x_homed, y_homed) - - except Exception as e: - print(f"Error updating home status: {e}") - import traceback - traceback.print_exc() - - def update_motion_status(self): - """Update motion status and emit signal if changed. - - The new driver's polling worker keeps am_moving[], am_error[] - up to date automatically via status update messages. - """ - if not self.is_connected or not self.controller: - return - - try: - # Check for any error conditions - if self.controller.am_error[0]: - self.error_occurred.emit("X-axis error detected") - if self.controller.am_error[1]: - self.error_occurred.emit("Y-axis error detected") - - # Read cached motion status (updated by polling worker) - x_moving = self.controller.am_moving[0] - y_moving = self.controller.am_moving[1] - - # Only emit if status changed - if x_moving != self.last_x_moving or y_moving != self.last_y_moving: - self.last_x_moving = x_moving - self.last_y_moving = y_moving - self.motion_status.emit(x_moving, y_moving) - - except Exception as e: - print(f"Error updating motion status: {e}") - import traceback - traceback.print_exc() - - # Slot methods for queuing commands - @QtCore.pyqtSlot() - def queue_connect(self): - """Queue a connect command""" - self.command_queue.put(MotionCommand('connect')) - - @QtCore.pyqtSlot() - def queue_disconnect(self): - """Queue a disconnect command""" - self.command_queue.put(MotionCommand('disconnect')) - - @QtCore.pyqtSlot(str, int) - def queue_jog(self, axis: str, direction: int): - """Queue a jog command""" - self.command_queue.put(MotionCommand('jog', axis=axis, direction=direction)) - - @QtCore.pyqtSlot(str) - def queue_home(self, axis: str): - """Queue a home command""" - self.command_queue.put(MotionCommand('home', axis=axis)) - - @QtCore.pyqtSlot(float, float) - def queue_set_velocity(self, speed: float, accel: float): - """Queue a set velocity command""" - self.command_queue.put(MotionCommand('set_velocity', speed=speed, accel=accel)) - - @QtCore.pyqtSlot(float) - def queue_set_step_size(self, step_size: float): - """Queue a set step size command""" - self.command_queue.put(MotionCommand('set_step_size', step_size=step_size)) - - @QtCore.pyqtSlot(str, bool) - def queue_set_axis_enable(self, axis: str, enabled: bool): - """Queue a command to enable or disable an axis""" - self.command_queue.put(MotionCommand('set_axis_enable', axis=axis, enabled=enabled)) - - @QtCore.pyqtSlot() - def stop(self): - """Stop the worker thread""" - # Set running to False immediately so the main loop can exit - # even if it's blocked waiting for a response from the controller - self.running = False - # Also queue a stop command to ensure the command_queue.get() returns - self.command_queue.put(MotionCommand('stop')) diff --git a/scanengine/new_scan_wizard.ui b/scanengine/new_scan_wizard.ui deleted file mode 100644 index 59910a3..0000000 --- a/scanengine/new_scan_wizard.ui +++ /dev/null @@ -1,1113 +0,0 @@ - - - Form - - - - 0 - 0 - 879 - 805 - - - - Form - - - - - - - - 0 - - - - - - - - - - 28 - - - - Enter Scan Metadata: - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - - 12 - - - - _SS_NNNN.wfm - - - - - - - - - Standalone Scan - - - - - - - Cooperative Scan - - - - - - - - - mm - - - - - - - - 12 - - - - Row Spacing: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - - 12 - - - - Number of -Angles: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - - 12 - - - - Waveform File -Prefix: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - - 100 - 30 - - - - - - - - - - - - 12 - - - - Data -Directory: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - - - - - - - - 100 - 16777215 - - - - Browse - - - - - - - - - - - 12 - - - - Scan Friendly -Name: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - - 100 - 30 - - - - - - - - - 12 - - - - Scan Type: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - Qt::Orientation::Horizontal - - - QSizePolicy::Policy::Fixed - - - - 200 - 0 - - - - - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - Number of -Cycles/Layers: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - - 100 - 16777215 - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - - - - - - - - 14 - - - - 000.00 - - - - - - - - 24 - - - - 000 - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - - 100 - 16777215 - - - - - - - - mm - - - - - - - - - - - Jog Up - - - - - - - - Noto Sans - - - - Axis -1 - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Jog Down - - - - - - - - - CH A Voltage [mV]: - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - Jog Up - - - - - - - - Noto Sans - - - - Axes -2+3 - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Jog Down - - - - - - - - - - - Enable Helios (Focusing Mode) - - - true - - - QPushButton:checked { background-color: #ff4444; color: white; font-weight: bold; } - - - - - - - Jog Stage - - - - - - - - 10 - 75 - true - - - - Camera Controls - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - Exposure: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignVCenter - - - - - - - 1 - - - 100 - - - 10 - - - Qt::Orientation::Horizontal - - - QSlider::TickPosition::TicksBelow - - - 10 - - - - - - - - 50 - 0 - - - - 10 ms - - - - - - - - - - - Gain: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignVCenter - - - - - - - 0 - - - 100 - - - 0 - - - Qt::Orientation::Horizontal - - - QSlider::TickPosition::TicksBelow - - - 10 - - - - - - - - 50 - 0 - - - - 0 - - - - - - - - - - - Wobble Distance: - - - - - - - - 24 - - - - 000 - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Stage X Coordinate: - - - - - - - - 400 - 400 - - - - - - - - - 24 - - - - 000 - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - 14 - - - - 000.00 - - - - - - - CH B Voltage [mV] - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Stage Y Coordinate: - - - - - - - - 28 - - - - Align Microscope: - - - - - - - Difference (A - B) - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Unlock Stage (Disables Wobble) - - - - - - - Align Y Axis - - - true - - - - - - - Align X Axis - - - true - - - true - - - - - - - Toggle Wobble - - - true - - - - - - - - - - - - - - - - 100 - 16777215 - - - - - - - - Y-Delta: - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - 100 - 16777215 - - - - - - - - X-Start: - - - - - - - Y-Start: - - - - - - - - 28 - - - - Define Scan Area: - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - 100 - 16777215 - - - - Qt::AlignmentFlag::AlignBottom|Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft - - - - - - - X-Delta: - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - 100 - 16777215 - - - - - - - - Draw Mode - - - - - - - Clear Scanning Bounds - - - - - - - - 300 - 300 - - - - - 400 - 400 - - - - - - - - Do LowRes Survey - - - - - - - Refine Survey - - - - - - - - - - - - - - - Scan Area: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - Pixel Size: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - Scan Coordinates: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - Scan Area: - - - - - - - - 28 - - - - Scan Summary: - - - - - - - Pixel Size: - - - - - - - - - - - - - - Start Scanning - - - - - - - Scan Saved At: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - Scan Friendly Name: - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - Scan Area: - - - - - - - Scan Saved At: - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - Scan Friendly Name: - - - - - - - - - - - - - - - Cancel - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - < Back - - - - - - - Next > - - - - - - - - - - - - diff --git a/scanengine/options.ui b/scanengine/options.ui deleted file mode 100644 index e121dca..0000000 --- a/scanengine/options.ui +++ /dev/null @@ -1,743 +0,0 @@ - - - Dialog - - - - 0 - 0 - 681 - 471 - - - - - Bahnschrift - 12 - - - - Dialog - - - - - - - - 0 - - - - Detection Laser - - - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - Scanning Power [mW]: - - - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Test Connection: - - - - - - - ??? - - - - - - - Query Laser / Test Connection - - - - - - - - - - - - - Laser SN: - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Laser Model: - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - - - Interlock State: - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Keyswitch State: - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - - - Main Heatsink Temp - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - ETA Temp - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - - - - - - Generation Laser - - - - - - - - - - Communication Port: - - - - - - - - - - Autodetect - - - - - - - - - - 11 - 75 - true - - - - Focusing Parameters: - - - - - - - - - Focusing Frequency [Hz]: - - - - - - - - - - - - - - Focusing Pump Current [mA]: - - - - - - - - - - - - Qt::Orientation::Vertical - - - QSizePolicy::Policy::Fixed - - - - 20 - 20 - - - - - - - - - 11 - 75 - true - - - - Scanning Parameters: - - - - - - - - - Scanning Frequency [Hz]: - - - - - - - - - - - - - - Scanning Pump Current [mA]: - - - - - - - - - - - - - - Effective Scan Direction Pixel Size: - - - - - - - mm px - - - - - - - - - - - Reset Laser (Required after interlock enable) - - - - - - - - - - - - Scanning Stage - - - - - - - - - - Scan Velocity [mm/s]: - - - - - - - - - - - - - - Scan Acceleration [mm/s2]: - - - - - - - - - - - - - - X-Axis Trigger Mode - - - - - - - - - - - - - - Y-Axis Trigger Mode - - - - - - - - - - - - - - Optical Axis X Coordinate [mm]: - - - - - - - - - - Optical Axis Y Coordinate [mm]: - - - - - - - - - - - - - - - T3R - - - - - - - - - - Comminucation Port: - - - - - - - - - - Autodetect - - - - - - - - - - - Test Connection - - - - - - - - - - - - - Firmware Version - - - Qt::AlignmentFlag::AlignCenter - - - - - - - FPGA Present? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Homed Status - - - Qt::AlignmentFlag::AlignCenter - - - - - - - FPGA MultiDivider -Enabled? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - FPGA RowPacking -Enabled? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - ??? - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - - - - - - Oscilloscope - - - - - - - - - - Oscilloscope Socket Address: - - - - - - - - - - - - - - Scratch Directory: - - - - - - - - - - Browse - - - - - - - - - - - Save to PC - - - - - - - Save to Oscilloscope - - - - - - - - - - - Test Connection - - - - - - - - - - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - Update Configuration - - - - - - - Cancel - - - - - - - - - - - -