Files
scanengine-3/app.py
Thomas Ales [M S E] a0e0151b5d pre uc480 integration
2026-05-22 09:38:39 -05:00

546 lines
21 KiB
Python

#!/usr/bin/env python3
"""
Scanengine 3 Main Application
"""
import sys
import json
from pathlib import Path
from PyQt6 import QtWidgets, QtCore
from typing import Optional
import serial.tools.list_ports
from hardware.coherent_hops_laser import CoherentHOPSLaser, DummyLaser
from hardware.helios_laser import HeliosLaser, PulseMode
from hardware.uc480_camera import UC480Camera, CameraStreamThread
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y, TriggerBitsServo
from motion_worker import MotionWorker
from scanning.stage_scan_plan_generator import StageScanPlanGenerator
from genesis_worker import GenesisWorker, GenesisCommand
from ui_mainwindow import Ui_MainWindow
# Page indices in stackedWidget
PAGE_START = 0
PAGE_OPTIONS = 1
PAGE_NEWSCAN = 2
PAGE_CONTINUESCAN = 3
PAGE_SCAN_PROGRESS = 4
CONFIG_PATH = Path(__file__).parent / "config.json"
DEFAULT_CONFIG = {
"stage": {
"serial_port": "",
"trigger": "Disabled",
"scan_velocity_mm_s": 200.0,
"scan_acceleration_mm_s2": 500.0,
"optical_axis_x_mm": 0.0,
"optical_axis_y_mm": 0.0,
},
"fpga": {
"serial_port": "",
"pulse_divider": 1,
"rowpack_enabled": False,
},
"t3r": {
"serial_port": "",
"t_axis_current_ma": 0.0,
"gr_axis_current_ma": 0.0,
"t_axis_microstepping": "Full Step",
"gr_axis_microstepping": "Full Step",
},
"oscilloscope": {
"ip_address": "",
},
"generation_laser": {
"serial_port": "",
"pulse_frequency_hz": 125000,
"diode_pump_current_ma": 0.0,
},
"detection_laser": {
"power_mw": 0.0,
},
"genesis_laser": {
"com_port": "/dev/ttyUSB0",
},
}
# Fixed option lists for combo boxes
TRIGGER_OPTIONS = [
"Disabled",
"Trigger Out: In Motion",
"Trigger Out: Motion Complete",
"Trigger Out: Max Velocity",
"Trigger Out: High at Max Velocity",
]
MICROSTEPPING_OPTIONS = [
"Full Step",
"Half Step",
"1/4 Step",
"1/8 Step",
"1/16 Step",
"1/32 Step",
]
class ScanWorker(QtCore.QObject):
"""Worker object for handling scanning in a separate thread."""
scan_started = QtCore.pyqtSignal()
scan_completed = QtCore.pyqtSignal()
scan_failed = QtCore.pyqtSignal(str)
angle_started = QtCore.pyqtSignal(int, int)
line_started = QtCore.pyqtSignal(int, int, float)
current_progress = QtCore.pyqtSignal(int)
overall_progress = QtCore.pyqtSignal(int)
status_message = QtCore.pyqtSignal(str)
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."""
if self.motion_worker:
self.motion_worker.scanning_active = True
try:
self.scan_started.emit()
# TODO: implement scan execution logic
self.scan_completed.emit()
except Exception as e:
self.scan_failed.emit(str(e))
finally:
if self.motion_worker:
self.motion_worker.scanning_active = False
def stop(self):
self.should_stop = True
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.config = self._load_config()
# Hardware objects
self.motion_worker: Optional[MotionWorker] = None
self.motion_thread: Optional[QtCore.QThread] = None
self.genesis_worker: Optional[GenesisWorker] = None
self.genesis_thread: Optional[QtCore.QThread] = None
self.camera: Optional[UC480Camera] = None
self.camera_stream: Optional[CameraStreamThread] = None
self.vis_laser: Optional[CoherentHOPSLaser] = None
self.ir_laser: Optional[HeliosLaser] = None
self.scan_worker: Optional[ScanWorker] = None
self.scan_thread: Optional[QtCore.QThread] = None
self._connect_signals()
self._init_genesis_worker()
self.ui.stackedWidget.setCurrentIndex(PAGE_START)
# ------------------------------------------------------------------
# Config
# ------------------------------------------------------------------
def _load_config(self) -> dict:
if CONFIG_PATH.exists():
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
for section, values in DEFAULT_CONFIG.items():
cfg.setdefault(section, {})
for key, val in values.items():
cfg[section].setdefault(key, val)
return cfg
except Exception:
pass
return {k: dict(v) for k, v in DEFAULT_CONFIG.items()}
def _save_config(self):
with open(CONFIG_PATH, "w") as f:
json.dump(self.config, f, indent=2)
# ------------------------------------------------------------------
# Signal wiring
# ------------------------------------------------------------------
def _connect_signals(self):
# Start page
self.ui.start_new_scan_btn.clicked.connect(self._go_to_newscan)
self.ui.resume_scan_btn.clicked.connect(self._go_to_continuescan)
self.ui.edit_options_btn.clicked.connect(self._go_to_options)
# Options page
self.ui.options_save_settings_btn.clicked.connect(self._on_options_save)
self.ui.options_cancel_btn.clicked.connect(self._go_to_start)
self.ui.stage_test_connection_btn.clicked.connect(self._on_test_stage_connection)
self.ui.fpga_connect_button.clicked.connect(self._on_fpga_connect)
self.ui.fpga_refresh_ports_btn.clicked.connect(self._on_fpga_refresh_ports)
self.ui.refresh_serial_ports_btn.clicked.connect(self._on_refresh_serial_ports)
self.ui.scope_connect_btn.clicked.connect(self._on_scope_connect)
self.ui.generation_connect_button.clicked.connect(self._on_generation_connect)
self.ui.detection_test_btn.clicked.connect(self._on_detection_test)
self.ui.t3r_connect_btn.clicked.connect(self._on_t3r_connect)
self.ui.t3r_refresh_ports_btn.clicked.connect(self._on_t3r_refresh_ports)
# New scan page
self.ui.newscan_browse_folders_btn.clicked.connect(self._on_newscan_browse)
self.ui.newscan_set_current_as_start_btn.clicked.connect(self._on_newscan_set_start)
self.ui.newscan_get_delta_from_current_btn.clicked.connect(self._on_newscan_get_delta)
self.ui.newscan_toggle_vis_laser_btn.clicked.connect(self._on_newscan_toggle_vis_laser)
self.ui.newscan_continue_to_next_btn.clicked.connect(self._on_newscan_start_scan)
self.ui.newscan_jog_x_pos_btn.pressed.connect(self._on_jog_x_pos_pressed)
self.ui.newscan_jog_x_pos_btn.released.connect(self._on_jog_stop)
self.ui.newscan_jog_x_neg_btn.pressed.connect(self._on_jog_x_neg_pressed)
self.ui.newscan_jog_x_neg_btn.released.connect(self._on_jog_stop)
self.ui.newscan_jog_y_pos_btn.pressed.connect(self._on_jog_y_pos_pressed)
self.ui.newscan_jog_y_pos_btn.released.connect(self._on_jog_stop)
self.ui.newscan_jog_y_neg_btn.pressed.connect(self._on_jog_y_neg_pressed)
self.ui.newscan_jog_y_neg_btn.released.connect(self._on_jog_stop)
# Continue scan page
self.ui.continuescan_resume_scans.clicked.connect(self._on_resume_scan)
# Scan progress page
self.ui.abort_scan_button.clicked.connect(self._on_abort_scan)
# ------------------------------------------------------------------
# Navigation
# ------------------------------------------------------------------
def _go_to_start(self):
self.ui.stackedWidget.setCurrentIndex(PAGE_START)
def _go_to_options(self):
self._populate_options_page()
self.ui.stackedWidget.setCurrentIndex(PAGE_OPTIONS)
def _go_to_newscan(self):
self._populate_newscan_page()
self.ui.stackedWidget.setCurrentIndex(PAGE_NEWSCAN)
def _go_to_continuescan(self):
self._populate_continuescan_page()
self.ui.stackedWidget.setCurrentIndex(PAGE_CONTINUESCAN)
def _go_to_scan_progress(self):
self.ui.stackedWidget.setCurrentIndex(PAGE_SCAN_PROGRESS)
# ------------------------------------------------------------------
# Options page
# ------------------------------------------------------------------
def _get_serial_ports(self) -> list[str]:
return sorted(p.device for p in serial.tools.list_ports.comports())
def _populate_combo(self, combo: QtWidgets.QComboBox, items: list[str], current: str):
"""Refill a combo box, re-selecting `current` if present."""
combo.blockSignals(True)
combo.clear()
combo.addItems(items)
idx = combo.findText(current)
if idx >= 0:
combo.setCurrentIndex(idx)
elif current:
combo.insertItem(0, current)
combo.setCurrentIndex(0)
combo.blockSignals(False)
def _populate_options_page(self):
cfg = self.config
ports = self._get_serial_ports()
# ---- Kinematics tab ----
self.ui.scan_velocity_edit.setText(str(cfg["stage"]["scan_velocity_mm_s"]))
self.ui.scan_accel_edit.setText(str(cfg["stage"]["scan_acceleration_mm_s2"]))
self.ui.optical_axis_x_edit.setText(str(cfg["stage"]["optical_axis_x_mm"]))
self.ui.optical_axis_y_edit.setText(str(cfg["stage"]["optical_axis_y_mm"]))
self.ui.stage_serial_edit.setText(cfg["stage"]["serial_port"])
self._populate_combo(self.ui.stage_trigger_combo, TRIGGER_OPTIONS, cfg["stage"]["trigger"])
# ---- Detection / VIS tab ----
self.ui.detection_power_edit.setText(str(cfg["detection_laser"]["power_mw"]))
# ---- Generation / IR tab ----
self._populate_combo(self.ui.comboBox, ports, cfg["generation_laser"]["serial_port"])
self.ui.generation_pulse_freq_edit.setText(str(cfg["generation_laser"]["pulse_frequency_hz"]))
self.ui.diode_pump_current_edit.setText(str(cfg["generation_laser"]["diode_pump_current_ma"]))
# ---- PulseDecimator tab ----
self._populate_combo(self.ui.fpga_serial_port, ports, cfg["fpga"]["serial_port"])
self.ui.fpga_divider_value_edit.setText(str(cfg["fpga"]["pulse_divider"]))
self.ui.checkBox.setChecked(cfg["fpga"]["rowpack_enabled"])
# ---- T3R-SL tab ----
self._populate_combo(self.ui.t3r_serial_port_edit, ports, cfg["t3r"]["serial_port"])
self.ui.lineEdit.setText(str(cfg["t3r"]["t_axis_current_ma"]))
self.ui.lineEdit_2.setText(str(cfg["t3r"]["gr_axis_current_ma"]))
self._populate_combo(self.ui.comboBox_2, MICROSTEPPING_OPTIONS, cfg["t3r"]["t_axis_microstepping"])
self._populate_combo(self.ui.comboBox_3, MICROSTEPPING_OPTIONS, cfg["t3r"]["gr_axis_microstepping"])
# ---- Oscilloscope tab ----
self.ui.scope_ip_address_edit.setText(cfg["oscilloscope"]["ip_address"])
def _on_options_save(self):
try:
# Kinematics
self.config["stage"]["scan_velocity_mm_s"] = float(self.ui.scan_velocity_edit.text())
self.config["stage"]["scan_acceleration_mm_s2"] = float(self.ui.scan_accel_edit.text())
self.config["stage"]["optical_axis_x_mm"] = float(self.ui.optical_axis_x_edit.text())
self.config["stage"]["optical_axis_y_mm"] = float(self.ui.optical_axis_y_edit.text())
self.config["stage"]["serial_port"] = self.ui.stage_serial_edit.text().strip()
self.config["stage"]["trigger"] = self.ui.stage_trigger_combo.currentText()
# Detection / VIS
self.config["detection_laser"]["power_mw"] = float(self.ui.detection_power_edit.text())
# Generation / IR
self.config["generation_laser"]["serial_port"] = self.ui.comboBox.currentText()
self.config["generation_laser"]["pulse_frequency_hz"] = int(self.ui.generation_pulse_freq_edit.text())
self.config["generation_laser"]["diode_pump_current_ma"] = float(self.ui.diode_pump_current_edit.text())
# PulseDecimator
self.config["fpga"]["serial_port"] = self.ui.fpga_serial_port.currentText()
self.config["fpga"]["pulse_divider"] = int(self.ui.fpga_divider_value_edit.text())
self.config["fpga"]["rowpack_enabled"] = self.ui.checkBox.isChecked()
# T3R-SL
self.config["t3r"]["serial_port"] = self.ui.t3r_serial_port_edit.currentText()
self.config["t3r"]["t_axis_current_ma"] = float(self.ui.lineEdit.text())
self.config["t3r"]["gr_axis_current_ma"] = float(self.ui.lineEdit_2.text())
self.config["t3r"]["t_axis_microstepping"] = self.ui.comboBox_2.currentText()
self.config["t3r"]["gr_axis_microstepping"] = self.ui.comboBox_3.currentText()
# Oscilloscope
self.config["oscilloscope"]["ip_address"] = self.ui.scope_ip_address_edit.text().strip()
except ValueError as e:
QtWidgets.QMessageBox.warning(self, "Invalid input", str(e))
return
self._save_config()
self._go_to_start()
def _refresh_serial_ports_for_combos(self, *combos: QtWidgets.QComboBox):
"""Re-populate serial port combos, preserving current selections."""
ports = self._get_serial_ports()
for combo in combos:
self._populate_combo(combo, ports, combo.currentText())
def _on_refresh_serial_ports(self):
self._refresh_serial_ports_for_combos(self.ui.comboBox)
def _on_fpga_refresh_ports(self):
self._refresh_serial_ports_for_combos(self.ui.fpga_serial_port)
def _on_t3r_refresh_ports(self):
self._refresh_serial_ports_for_combos(self.ui.t3r_serial_port_edit)
def _on_test_stage_connection(self):
pass # TODO
def _on_fpga_connect(self):
pass # TODO
def _on_scope_connect(self):
pass # TODO
def _on_generation_connect(self):
pass # TODO
def _on_detection_test(self):
pass # TODO
def _on_t3r_connect(self):
pass # TODO
# ------------------------------------------------------------------
# New scan page
# ------------------------------------------------------------------
def _populate_newscan_page(self):
self.ui.newscan_save_directory_edit.setText(str(Path.home() / "scans"))
def _on_newscan_browse(self):
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select save directory")
if directory:
self.ui.newscan_save_directory_edit.setText(directory)
def _on_newscan_set_start(self):
pass # TODO: capture current stage position as scan start
def _on_newscan_get_delta(self):
pass # TODO: capture current stage position as scan end (compute delta)
def _on_newscan_toggle_vis_laser(self):
pass # TODO: toggle vis laser on/off
def _on_newscan_start_scan(self):
scan_params = self._build_scan_params()
if scan_params is None:
return
self._start_scan(scan_params)
def _build_scan_params(self) -> Optional[dict]:
"""Read newscan page widgets and return scan parameter dict, or None on error."""
try:
x_start = float(self.ui.newscan_start_x_coord_edit.text())
y_start = float(self.ui.newscan_start_y_coord_edit.text())
x_delta = float(self.ui.newscan_delta_x_coord_edit.text())
y_delta = float(self.ui.newscan_delta_y_coord_edit.text())
except ValueError:
QtWidgets.QMessageBox.warning(self, "Invalid input", "Scan coordinates must be numbers.")
return None
pixel_size_map = {
self.ui.newscan_50_micron_radio: 0.05,
self.ui.newscan_100_micron_radio: 0.10,
self.ui.newscan_250_micron_radio: 0.25,
}
row_spacing = next(
(v for btn, v in pixel_size_map.items() if btn.isChecked()), 0.10
)
return {
"x_start_mm": x_start,
"y_start_mm": y_start,
"x_delta_mm": x_delta,
"y_delta_mm": y_delta,
"row_spacing_mm": row_spacing,
"num_angles": int(self.ui.newscan_num_angles_combo.currentText()),
"friendly_name": self.ui.newscan_friendly_name_edit.text(),
"file_prefix": self.ui.newcsan_file_prefix_edit.text(),
"save_directory": self.ui.newscan_save_directory_edit.text(),
"scan_velocity_mm_s": self.config["stage"]["scan_velocity_mm_s"],
"scan_acceleration_mm_s2": self.config["stage"]["scan_acceleration_mm_s2"],
}
# ------------------------------------------------------------------
# Jog controls
# ------------------------------------------------------------------
def _on_jog_x_pos_pressed(self):
pass # TODO
def _on_jog_x_neg_pressed(self):
pass # TODO
def _on_jog_y_pos_pressed(self):
pass # TODO
def _on_jog_y_neg_pressed(self):
pass # TODO
def _on_jog_stop(self):
pass # TODO
# ------------------------------------------------------------------
# Continue scan page
# ------------------------------------------------------------------
def _populate_continuescan_page(self):
pass # TODO: populate list of interrupted scans
def _on_resume_scan(self):
pass # TODO: resume selected scan
# ------------------------------------------------------------------
# Scan execution
# ------------------------------------------------------------------
def _start_scan(self, scan_params: dict):
self.scan_thread = QtCore.QThread()
self.scan_worker = ScanWorker(scan_params, self.motion_worker)
self.scan_worker.moveToThread(self.scan_thread)
self.scan_thread.started.connect(self.scan_worker.run_scan)
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.current_progress.connect(self.ui.scanning_scan_progbar.setValue)
self.scan_worker.overall_progress.connect(self.ui.scanning_overall_progbar.setValue)
self.scan_worker.status_message.connect(self.ui.scanning_stage_state_label.setText)
self._go_to_scan_progress()
self.scan_thread.start()
@QtCore.pyqtSlot()
def _on_scan_started(self):
self.ui.abort_scan_button.setEnabled(True)
@QtCore.pyqtSlot()
def _on_scan_completed(self):
self._cleanup_scan_thread()
QtWidgets.QMessageBox.information(self, "Scan complete", "Scan finished successfully.")
self._go_to_start()
@QtCore.pyqtSlot(str)
def _on_scan_failed(self, error: str):
self._cleanup_scan_thread()
QtWidgets.QMessageBox.critical(self, "Scan failed", error)
self._go_to_start()
def _on_abort_scan(self):
if self.scan_worker:
self.scan_worker.stop()
def _cleanup_scan_thread(self):
if self.scan_thread:
self.scan_thread.quit()
self.scan_thread.wait()
self.scan_thread = None
self.scan_worker = None
# ------------------------------------------------------------------
# Genesis laser worker
# ------------------------------------------------------------------
def _init_genesis_worker(self):
com_port = self.config.get("genesis_laser", {}).get("com_port", "/dev/ttyUSB0")
self.genesis_worker = GenesisWorker(com_port)
self.genesis_thread = QtCore.QThread()
self.genesis_worker.moveToThread(self.genesis_thread)
self.genesis_thread.started.connect(self.genesis_worker.run)
self.genesis_thread.start()
def _cleanup_genesis_worker(self):
if self.genesis_worker:
self.genesis_worker.stop()
if self.genesis_thread:
self.genesis_thread.quit()
self.genesis_thread.wait()
self.genesis_worker = None
self.genesis_thread = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def closeEvent(self, event):
self._cleanup_genesis_worker()
self._cleanup_scan_thread()
if self.motion_thread:
self.motion_thread.quit()
self.motion_thread.wait()
super().closeEvent(event)
def main():
app = QtWidgets.QApplication(sys.argv)
qss_path = Path(__file__).parent / "app_style.qss"
if qss_path.exists():
app.setStyleSheet(qss_path.read_text())
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()