""" nueScan - Main Window Controller Handles all UI interactions and coordinates hardware communication Copyright (C) 2025 Thomas Ales Licensed under GNU General Public License v2.0 """ import os from PyQt6 import uic from PyQt6.QtWidgets import QMainWindow, QMessageBox from PyQt6.QtCore import QTimer # Import dialog controllers from dialogs.genesis_dialog import GenesisDialog from dialogs.helios_dialog import HeliosDialog from dialogs.scan_active_dialog import ScanActiveDialog from dialogs.status_dialog import StatusDialog from dialogs.oscope_dialog import OscopeDialog # Import hardware controllers from hardware.thorlabs_stage import ThorLabsStage from hardware.t3r_device import T3RDevice from hardware.microscope import MicroscopeController class NueScanMainWindow(QMainWindow): """Main window for nueScan application""" def __init__(self): super().__init__() # Load UI file ui_path = os.path.join(os.path.dirname(__file__), 'nuescan_mainwindow.ui') uic.loadUi(ui_path, self) # Set window title self.setWindowTitle("nueScan - SRAS Scan Planning and Control") # Initialize hardware controllers self.thorlabs_stage = ThorLabsStage() self.t3r_device = T3RDevice() self.microscope = MicroscopeController() # Initialize dialogs (create on demand) self.genesis_dialog = None self.helios_dialog = None self.scan_active_dialog = None self.status_dialog = StatusDialog(self, self.thorlabs_stage, self.t3r_device, self.microscope) self.oscope_dialog = OscopeDialog(self, self.microscope) # Status update timer self.status_timer = QTimer() self.status_timer.timeout.connect(self._update_all_status) self.status_timer.start(100) # Update every 100ms # Connect all UI signals self._connect_signals() # Initialize UI state self._initialize_ui() def _connect_signals(self): """Connect all UI signals to handler methods""" # ===== Button Click Handlers ===== self.btn_toggle_mls.clicked.connect(self.on_toggle_mls_clicked) self.btn_refresh_com.clicked.connect(self.on_refresh_com_clicked) self.button_connect_com.clicked.connect(self.on_connect_com_clicked) self.btn_begin_scanning.clicked.connect(self.on_begin_scanning_clicked) self.btn_toggle_status_window.clicked.connect(self.on_toggle_status_window_clicked) self.actionShow_Oscope_Settings.triggered.connect(self.on_show_oscope_settings_clicked) self.actionDigital_IO_State.triggered.connect(self.on_show_digital_io_state_clicked) self.actionMLS203_Information.triggered.connect(self.on_show_mls203_information_clicked) self.actionTransfer_System_Editor.triggered.connect(self.on_show_transfer_system_editor_clicked) self.actionHelios_Settings.triggered.connect(self.on_show_helios_settings_clicked) self.actionGenesis_Settings.triggered.connect(self.on_show_genesis_settings_clicked) # ===== LineEdit Text Changed Handlers ===== self.le_stage_serial.textChanged.connect(self.on_stage_serial_changed) self.le_x_start_coord.textChanged.connect(self.on_x_start_coord_changed) self.le_x_delta.textChanged.connect(self.on_x_delta_changed) self.le_y_start_coord.textChanged.connect(self.on_y_start_coord_changed) self.le_y_delta.textChanged.connect(self.on_y_delta_changed) self.le_file_suffix.textChanged.connect(self.on_file_suffix_changed) # ===== ComboBox Value Changed Handlers ===== self.cb_num_scans.currentIndexChanged.connect(self.on_num_scans_changed) self.cb_row_spacing.currentIndexChanged.connect(self.on_row_spacing_changed) def _initialize_ui(self): """Initialize UI with default values""" # Set placeholder text for stage serial self.le_stage_serial.setPlaceholderText("Enter BBD203 serial (e.g., 83123456)") # Refresh COM ports self.on_refresh_com_clicked() # ==================== Button Click Handlers ==================== def on_toggle_status_window_clicked(self): """Toggle the visibility of the status window""" if self.status_dialog.isVisible(): self.status_dialog.hide() else: self.status_dialog.show() def on_show_oscope_settings_clicked(self): """Toggle the visibility of the oscope settings window""" if self.oscope_dialog.isVisible(): self.oscope_dialog.hide() else: self.oscope_dialog.show() def on_show_digital_io_state_clicked(self): """Show the digital IO state dialog""" print("DEBUG: Show digital IO state clicked") def on_show_mls203_information_clicked(self): """Show the MLS203 information dialog""" print("DEBUG: Show MLS203 information clicked") def on_show_transfer_system_editor_clicked(self): """Show the transfer system editor dialog""" print("DEBUG: Show transfer system editor clicked") def on_toggle_mls_clicked(self): """Handle ThorLabs MLS stage connect/disconnect""" print("DEBUG: MLS toggle button clicked") if self.thorlabs_stage.is_connected(): self.thorlabs_stage.disconnect() self.btn_toggle_mls.setText("Connect") else: serial_number = self.le_stage_serial.text().strip() if not serial_number: QMessageBox.warning( self, "No Serial Number", "Please enter the BBD203 serial number.\n\n" "The serial number is printed on the controller label\n" "(e.g., '83123456')." ) return print(f"INFO: Attempting to connect to BBD203 serial: {serial_number}") success = self.thorlabs_stage.connect(serial_number) if success: self.btn_toggle_mls.setText("Disconnect") QMessageBox.information( self, "Connected", f"Successfully connected to BBD203 controller\n" f"Serial: {serial_number}\n\n" f"All channels enabled. Ready to home axes." ) else: # Show available devices devices = self.thorlabs_stage.list_devices() if devices: device_list = "\n".join([ f" Serial: {d['serial']} ({d['description']})" for d in devices ]) msg = (f"Failed to connect to BBD203 with serial: {serial_number}\n\n" f"Available ThorLabs devices:\n{device_list}") else: msg = (f"Failed to connect to BBD203 with serial: {serial_number}\n\n" f"No ThorLabs devices found.\n" f"Check USB connection and driver installation.") QMessageBox.warning(self, "Connection Error", msg) def on_refresh_com_clicked(self): """Refresh available COM ports""" print("DEBUG: Refresh COM ports clicked") self.combo_com_ports.clear() ports = self.t3r_device.get_available_ports() self.combo_com_ports.addItems(ports) def on_connect_com_clicked(self): """Connect to selected COM port""" print("DEBUG: Connect COM button clicked") port = self.combo_com_ports.currentText() if port: success = self.t3r_device.connect(port) if success: self.button_connect_com.setText("Disconnect") else: QMessageBox.warning(self, "Connection Error", f"Failed to connect to {port}") else: QMessageBox.warning(self, "No Port Selected", "Please select a COM port") def on_show_helios_settings_clicked(self): """Show Helios settings dialog""" print("DEBUG: Show Helios settings clicked") if not self.helios_dialog: self.helios_dialog = HeliosDialog(self) if self.helios_dialog.exec(): # User clicked OK, apply settings settings = self.helios_dialog.get_settings() self.microscope.apply_helios_settings(settings) print(f"DEBUG: Applied Helios settings: {settings}") def on_show_genesis_settings_clicked(self): """Show Genesis settings dialog""" print("DEBUG: Show Genesis settings clicked") if not self.genesis_dialog: self.genesis_dialog = GenesisDialog(self) if self.genesis_dialog.exec(): # User clicked OK, apply settings settings = self.genesis_dialog.get_settings() self.microscope.apply_genesis_settings(settings) print(f"DEBUG: Applied Genesis settings: {settings}") def on_begin_scanning_clicked(self): """Start the scanning process""" print("DEBUG: Begin scanning clicked") # Validate that all systems are ready if not self._validate_scan_ready(): return # Create and show scan active dialog if not self.scan_active_dialog: self.scan_active_dialog = ScanActiveDialog(self) # Start the scan self._start_scan() # Show progress dialog self.scan_active_dialog.exec() # ==================== ComboBox Change Handlers ==================== def on_num_scans_changed(self, index): """Handle number of scans change""" num_scans = self.cb_num_scans.currentText() print(f"DEBUG: Number of scans changed to: {num_scans}") self._recalculate_scan_parameters() def on_row_spacing_changed(self, index): """Handle row spacing change""" spacing = self.cb_row_spacing.currentText() print(f"DEBUG: Row spacing changed to: {spacing}") self._recalculate_scan_parameters() # ==================== LineEdit Text Changed Handlers ==================== def on_stage_serial_changed(self, text): """Handle stage serial number change""" print(f"DEBUG: Stage serial changed to: {text}") def on_x_start_coord_changed(self, text): """Handle X start coordinate change""" print(f"DEBUG: X start coordinate changed to: {text}") self._recalculate_scan_parameters() def on_x_delta_changed(self, text): """Handle X delta change""" print(f"DEBUG: X delta changed to: {text}") self._recalculate_scan_parameters() def on_y_start_coord_changed(self, text): """Handle Y start coordinate change""" print(f"DEBUG: Y start coordinate changed to: {text}") self._recalculate_scan_parameters() def on_y_delta_changed(self, text): """Handle Y delta change""" print(f"DEBUG: Y delta changed to: {text}") self._recalculate_scan_parameters() def on_file_suffix_changed(self, text): """Handle file suffix change""" print(f"DEBUG: File suffix changed to: {text}") # ==================== Status Update Methods ==================== def _update_all_status(self): """Update all status labels with current hardware states""" self.status_dialog.update_all_status() # ==================== Scan Management Methods ==================== def _validate_scan_ready(self): """Validate that all systems are ready for scanning""" if not self.thorlabs_stage.is_connected(): QMessageBox.warning( self, "Not Ready", "ThorLabs stage is not connected") return False if not self.t3r_device.is_connected(): QMessageBox.warning(self, "Not Ready", "T3R device is not connected") return False # Add more validation as needed return True def _start_scan(self): """Initialize and start the scanning process""" print("DEBUG: Starting scan process") # Collect scan parameters params = self._collect_scan_parameters() # Initialize hardware for scanning self.thorlabs_stage.prepare_for_scan(params) self.t3r_device.prepare_for_scan(params) self.microscope.prepare_for_scan(params) # Start the scan (would be implemented in actual hardware controllers) print(f"DEBUG: Scan parameters: {params}") def _collect_scan_parameters(self): """Collect all scan parameters from UI""" try: params = { 'x_start': float(self.le_x_start_coord.text() or 0), 'x_delta': float(self.le_x_delta.text() or 0), 'y_start': float(self.le_y_start_coord.text() or 0), 'y_delta': float(self.le_y_delta.text() or 0), 'num_scans': int(self.cb_num_scans.currentText() or 1), 'row_spacing': float(self.cb_row_spacing.currentText() or 0.1), 'file_suffix': self.le_file_suffix.text(), 'pd_trig_voltage': float(self.oscope_dialog.le_set_pd_trig_voltage.text() or 0), 'sample_min_bias_voltage': float(self.oscope_dialog.le_set_sample_thresh_voltage.text() or 0), 'trigger_voltage': float(self.oscope_dialog.le_set_trigger_voltage.text() or 0), 'visa_address': self.oscope_dialog.le_oscope_visa_address.text(), 'phototrigger_channel': self.oscope_dialog.cb_set_trig_channel.currentText(), 'bias_a_channel': self.oscope_dialog.cb_set_bias_a_ch.currentText(), 'bias_b_channel': self.oscope_dialog.cb_set_bias_b_ch.currentText(), 'rf_saw_channel': self.oscope_dialog.cb_set_saw_channel.currentText() } except ValueError: params = { 'x_start': 0, 'x_delta': 0, 'y_start': 0, 'y_delta': 0, 'num_scans': 1, 'row_spacing': 0.1, 'file_suffix': '', 'pd_trig_voltage': 0, 'sample_min_bias_voltage': 0, 'trigger_voltage': 0, 'visa_address': '', 'phototrigger_channel': 'CH1', 'bias_a_channel': 'CH1', 'bias_b_channel': 'CH1', 'rf_saw_channel': 'CH1' } return params def _recalculate_scan_parameters(self): """Recalculate and update scan statistics""" params = self._collect_scan_parameters() # Calculate points per row (stub calculation) if params['x_delta'] > 0: points_per_row = int(abs(params['x_start']) / params['x_delta']) else: points_per_row = 0 # Calculate rows per scan (stub calculation) if params['row_spacing'] > 0 and params['y_delta'] > 0: rows_per_scan = int(abs(params['y_delta']) / params['row_spacing']) else: rows_per_scan = 0 # Calculate totals scans_in_set = params['num_scans'] total_records = points_per_row * rows_per_scan * scans_in_set total_points = total_records # Estimate file size (1KB per point as example) estimated_size_gb = (total_points * 1024) / (1024 ** 3) # Update labels self.l_scan_ppr.setText(str(points_per_row)) self.l_scan_rps.setText(str(rows_per_scan)) self.l_scan_sis.setText(str(scans_in_set)) self.l_scan_total_records.setText(str(total_records)) self.l_scan_total_points.setText(str(total_points)) self.l_scan_estimated_size.setText(f"{estimated_size_gb:.2f}GB") # Calculate angle spacing if scans_in_set > 1: angle_spacing = 360.0 / scans_in_set else: angle_spacing = 0 self.l_scan_angle_spacing.setText(f"{angle_spacing:.2f}°") # ==================== Progress Update Methods ==================== def update_total_progress(self, current, total): """ Update total scan progress Called from scan control logic to update progress bar """ if self.scan_active_dialog: self.scan_active_dialog.update_total_progress(current, total) def update_current_scan_progress(self, current, total): """ Update current scan progress Called from scan control logic to update progress bar """ if self.scan_active_dialog: self.scan_active_dialog.update_current_scan_progress(current, total) def update_scan_status(self, scan_num, total_scans, row_num, total_rows, time_remaining): """ Update scan status information Called from scan control logic """ if self.scan_active_dialog: self.scan_active_dialog.update_status( scan_num, total_scans, row_num, total_rows, time_remaining )