Phase 1a: delete dead parallel app stack (~4,600 lines)

app.py was an abandoned skeleton (15 'pass # TODO' handlers, loads a
deleted .qss); everything else was reachable only from it:
ui_mainwindow.py (pyuic6 artifact), sc3-new.ui, motion_worker.py,
genesis_worker.py, coherent_hops_laser.py (stubs), scanning/ (dead C#
port + unused plan generator), config.json, plus helios_diagnostic.py
(sends wrong protocol commands) and helios_terminal.py (worse duplicate
of helios_test_app's Terminal tab).

hardware/__init__.py no longer wildcard-imports every driver, so the
stage driver imports without the uEye camera SDK installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-28 10:02:36 -05:00
parent d185676130
commit 1d4e65f8ac
13 changed files with 6 additions and 6288 deletions
-615
View File
@@ -1,615 +0,0 @@
#!/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 hardware.t3r_driver import T3RDriver
from motion_worker import MotionWorker
from scanning.stage_scan_plan_generator import StageScanPlanGenerator
from genesis_worker import GenesisWorker, GenesisCommand
from t3r_control_panel import T3RControlPanel
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, t3r_driver=None):
super().__init__()
self.scan_params = scan_params
self.motion_worker = motion_worker
self.t3r_driver = t3r_driver
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()
num_angles = self.scan_params.get("num_angles", 1)
angle_step = 360.0 / num_angles if num_angles > 1 else 0.0
gr_microsteps = self.scan_params.get("gr_axis_microsteps", 16)
for angle_idx in range(num_angles):
if self.should_stop:
break
self.angle_started.emit(angle_idx, num_angles)
self.status_message.emit(
f"Scanning angle {angle_idx + 1}/{num_angles}")
# TODO: execute scan lines for this angle via motion_worker
if angle_idx < num_angles - 1 and angle_step and self.t3r_driver:
if self.t3r_driver.is_open:
self.status_message.emit(
f"Rotating stage {angle_step:.3f}° for next angle…")
self.t3r_driver.rotate_stage(
angle_step,
gr_microsteps,
self.scan_params.get("rotation_velocity", 8000),
self.scan_params.get("rotation_accel", 4000),
)
# TODO: wait for MOTION_DONE event before proceeding
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
# T3R focusing / rotation driver (lives in main thread; reader runs internally)
self.t3r_driver = T3RDriver(self)
self.t3r_panel: Optional[T3RControlPanel] = None
self._connect_signals()
self._init_genesis_worker()
self._init_t3r_menu()
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):
port = self.ui.t3r_serial_port_edit.currentText().split(" ")[0]
self._show_t3r_panel()
if port and not self.t3r_driver.is_open:
try:
self.t3r_driver.connect(port)
except Exception as exc:
QtWidgets.QMessageBox.warning(self, "T3R Connect", str(exc))
# ------------------------------------------------------------------
# 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"],
# T3R rotation between angles (GR-axis, ch1)
"gr_axis_microsteps": 16,
"rotation_velocity": 8000,
"rotation_accel": 4000,
}
# ------------------------------------------------------------------
# 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.t3r_driver)
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
# ------------------------------------------------------------------
# T3R focusing / rotation panel
# ------------------------------------------------------------------
def _init_t3r_menu(self):
"""Add a Hardware menu with a T3R panel toggle action."""
hw_menu = self.menuBar().addMenu("Hardware")
self._t3r_action = hw_menu.addAction("T3R Focusing && Rotation…")
self._t3r_action.setCheckable(True)
self._t3r_action.setShortcut("Ctrl+T")
self._t3r_action.triggered.connect(self._on_t3r_action_toggled)
def _show_t3r_panel(self):
if self.t3r_panel is None:
self.t3r_panel = T3RControlPanel(self.t3r_driver, self)
self.t3r_panel.finished.connect(
lambda: self._t3r_action.setChecked(False))
self.t3r_panel.show()
self.t3r_panel.raise_()
self._t3r_action.setChecked(True)
def _on_t3r_action_toggled(self, checked: bool):
if checked:
self._show_t3r_panel()
elif self.t3r_panel is not None:
self.t3r_panel.hide()
# ------------------------------------------------------------------
# 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):
if self.t3r_driver.is_open:
self.t3r_driver.disconnect()
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()
-31
View File
@@ -1,31 +0,0 @@
{
"genesis_laser": {
"com_port": "/dev/ttyUSB0"
},
"detection_laser": {
"scan_power_mw": "125"
},
"generation_laser": {
"com_port": "/dev/ttyACM0",
"frequency_hz": "20000",
"pump_diode_current_ma": "750",
"focusing_frequency_hz": "20000",
"focusing_pump_current_ma": "300"
},
"scanning_stage": {
"scan_velocity_mm_s": "200",
"scan_acceleration_mm_s2": "1500",
"x_trigger_mode": 6,
"y_trigger_mode": 0,
"optical_axis_x_mm": "55",
"optical_axis_y_mm": "37.5"
},
"t3r": {
"com_port": "/dev/ttyUSB0"
},
"oscilloscope": {
"socket_address": "192.168.0.1",
"scratch_directory": "/opt/",
"save_location": "pc"
}
}
-193
View File
@@ -1,193 +0,0 @@
"""
Genesis Laser Worker Thread
Manages Genesis laser connection in a separate thread to keep the UI responsive.
Provides async querying and status monitoring via Qt signals.
"""
from PyQt6 import QtCore
from hardware.genesis_core import SerialComm, I2CProtocol, I2CDevices, LaserControl
import queue
import time
from typing import Optional
class GenesisCommand:
"""Represents a genesis laser command"""
def __init__(self, cmd_type: str, **kwargs):
self.cmd_type = cmd_type
self.params = kwargs
class GenesisWorker(QtCore.QObject):
"""
Worker object for handling Genesis laser control in a separate thread.
Signals:
connected: Emitted when laser connects successfully
disconnected: Emitted when laser disconnects
connection_failed: Emitted when connection fails (error_msg: str)
laser_info_updated: Emitted with laser status
query_completed: Emitted when a query operation completes (result: dict)
error_occurred: Emitted when an error occurs (error_msg: str)
"""
# Signals
connected = QtCore.pyqtSignal()
disconnected = QtCore.pyqtSignal()
connection_failed = QtCore.pyqtSignal(str)
laser_info_updated = QtCore.pyqtSignal(dict) # Status information
query_completed = QtCore.pyqtSignal(dict) # Query result
error_occurred = QtCore.pyqtSignal(str) # Error message
def __init__(self, port: str = "/dev/ttyUSB0", baudrate: int = 9600):
super().__init__()
self.serial_comm = SerialComm()
self.i2c_protocol = I2CProtocol(self.serial_comm)
self.i2c_devices = I2CDevices(self.i2c_protocol)
self.laser_control = LaserControl(self.i2c_devices)
self.port = port
self.baudrate = baudrate
self.is_connected = False
self.command_queue = queue.Queue()
self.running = True
# Last known laser state
self.last_laser_state = {}
# Update interval for status polling
self.last_status_update_time = 0
self.status_update_interval = 1.0 # seconds
@QtCore.pyqtSlot()
def run(self):
"""Main worker loop - processes commands from queue"""
print(f"Genesis laser worker thread started - connecting to {self.port}")
# Try to connect on startup
if self.connect():
self.connected.emit()
else:
error_msg = f"Failed to connect to Genesis laser on {self.port}"
print(error_msg)
self.connection_failed.emit(error_msg)
while self.running:
try:
# Check for commands with timeout to allow periodic status updates
try:
cmd = self.command_queue.get(timeout=0.05) # 50ms timeout
self.process_command(cmd)
except queue.Empty:
pass
# Periodically update status if connected
if self.is_connected:
current_time = time.time()
if current_time - self.last_status_update_time >= self.status_update_interval:
self.update_laser_status()
self.last_status_update_time = current_time
except Exception as e:
print(f"Error in genesis worker loop: {e}")
self.error_occurred.emit(str(e))
# Cleanup on exit
self.disconnect()
print("Genesis laser worker thread stopped")
def connect(self) -> bool:
"""Establish connection to the laser"""
try:
if self.serial_comm.connect(self.port, self.baudrate):
self.is_connected = True
print(f"Connected to Genesis laser on {self.port}")
return True
else:
print(f"Failed to open serial port {self.port}")
return False
except Exception as e:
print(f"Connection error: {e}")
return False
def disconnect(self):
"""Disconnect from the laser"""
if self.is_connected:
self.serial_comm.disconnect()
self.is_connected = False
self.disconnected.emit()
print("Disconnected from Genesis laser")
def process_command(self, cmd: GenesisCommand):
"""Process a command from the queue"""
if not self.is_connected:
self.error_occurred.emit("Laser not connected")
return
try:
if cmd.cmd_type == "query_all":
result = self.query_all_status()
self.query_completed.emit(result)
elif cmd.cmd_type == "query_current":
result = {"current": self.laser_control.get_current_actual()}
self.query_completed.emit(result)
elif cmd.cmd_type == "query_interlock":
result = {"interlock": self.laser_control.get_interlock_status()}
self.query_completed.emit(result)
elif cmd.cmd_type == "set_current":
value = cmd.params.get("value", 0)
success = self.laser_control.set_current(int(value))
self.query_completed.emit({"success": success})
elif cmd.cmd_type == "set_shutter":
state = cmd.params.get("state", False)
success = self.laser_control.set_shutter(state)
self.query_completed.emit({"success": success})
else:
self.error_occurred.emit(f"Unknown command: {cmd.cmd_type}")
except Exception as e:
self.error_occurred.emit(f"Command execution error: {e}")
def update_laser_status(self):
"""Query and emit current laser status"""
if not self.is_connected:
return
try:
status = {
"connected": True,
"current_actual": self.laser_control.get_current_actual(),
"interlock_status": self.laser_control.get_interlock_status(),
"ldd_enable_status": self.laser_control.get_ldd_enable_status(),
"psglue_in_status": self.laser_control.get_psglue_in_status(),
"psglue_out_status": self.laser_control.get_psglue_out_status(),
"head_dio_status": self.laser_control.get_head_dio_status(),
}
# Only emit if something changed
if status != self.last_laser_state:
self.last_laser_state = status
self.laser_info_updated.emit(status)
except Exception as e:
print(f"Error updating laser status: {e}")
def query_all_status(self) -> dict:
"""Query all laser status information"""
return {
"connected": True,
"current_actual": self.laser_control.get_current_actual(),
"interlock_status": self.laser_control.get_interlock_status(),
"ldd_enable_status": self.laser_control.get_ldd_enable_status(),
"psglue_in_status": self.laser_control.get_psglue_in_status(),
"psglue_out_status": self.laser_control.get_psglue_out_status(),
"head_dio_status": self.laser_control.get_head_dio_status(),
}
def queue_command(self, cmd: GenesisCommand):
"""Queue a command for execution"""
self.command_queue.put(cmd)
def stop(self):
"""Stop the worker thread"""
self.running = False
+6 -7
View File
@@ -1,7 +1,6 @@
"""Hardware driver modules for ScanEngine-3"""
from .pybbd202 import ThorlabsServoDriver, TriggerBitsServo, AXIS_X, AXIS_Y, CONTROLLER
from .t3r_driver import T3RDriver
from .uc480_camera import *
from .tektronix_base import *
from .coherent_hops_laser import *
from .genesis_core import *
"""Hardware driver modules for ScanEngine-3.
Import drivers by module (e.g. ``from hardware.t3r_driver import T3RDriver``);
nothing is re-exported here so that importing one driver never drags in
another driver's SDK (the uEye camera stack in particular).
"""
-45
View File
@@ -1,45 +0,0 @@
"""
Coherent HOPS Laser Driver - Stub Module
This is a temporary stub to allow testing camera integration.
"""
class CoherentHOPSLaser:
"""Stub class for Coherent HOPS Laser"""
pass
class DummyLaser:
"""Dummy laser for testing without hardware"""
def connect(self):
"""Simulate connection"""
pass
def disconnect(self):
"""Simulate disconnection"""
pass
def get_hardware_id(self):
"""Return simulated hardware ID"""
return "SIM-12345"
def get_laser_model(self):
"""Return simulated model"""
return "Genesis Simulator"
def get_interlock_status(self):
"""Return simulated interlock status"""
return "OK"
def get_key_switch_status(self):
"""Return simulated key switch status"""
return "ON"
def get_temperature_main(self):
"""Return simulated main temperature"""
return 25.5
def get_temperature_eta(self):
"""Return simulated ETA temperature"""
return 26.3
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env python3
"""
Helios Laser Serial Communication Diagnostic Tool
Helps troubleshoot communication issues with the Helios laser.
"""
import serial
import time
import sys
def test_port(port, baudrate=9600):
"""Test basic communication on a serial port."""
print(f"\n{'='*60}")
print(f"Testing {port} at {baudrate} baud")
print(f"{'='*60}")
try:
ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1.0
)
print(f"✓ Port opened successfully")
time.sleep(0.1)
# Try to query the controller serial number
print("\nSending: 'SN?'")
ser.write(b'SN?\r')
time.sleep(0.5)
response = ser.readline().decode('ascii', errors='replace').strip()
print(f"Response: '{response}'")
if response:
print(f"✓ Got response: {response}")
return True, response
else:
print(f"✗ No response received")
# Try head serial number
print("\nSending: 'HSN?'")
ser.write(b'HSN?\r')
time.sleep(0.5)
response = ser.readline().decode('ascii', errors='replace').strip()
print(f"Response: '{response}'")
if response:
print(f"✓ Got response: {response}")
ser.close()
return True, response
else:
print(f"✗ No response received")
# Try laser enable status
print("\nSending: 'LE?'")
ser.write(b'LE?\r')
time.sleep(0.5)
response = ser.readline().decode('ascii', errors='replace').strip()
print(f"Response: '{response}'")
if response:
print(f"✓ Got response: {response}")
ser.close()
return True, response
else:
print(f"✗ No response received")
ser.close()
return False, "No response to any query"
except Exception as e:
print(f"✗ Error: {e}")
return False, str(e)
def test_raw_communication(port, baudrate=9600):
"""Test raw serial communication and display hex."""
print(f"\n{'='*60}")
print(f"Raw Communication Test: {port} at {baudrate} baud")
print(f"{'='*60}")
try:
ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=2.0
)
print(f"✓ Port opened successfully")
time.sleep(0.2)
# Send a simple query
command = b'SN?\r'
print(f"\nSending command (hex): {command.hex()}")
print(f"Sending command (ascii): {command}")
ser.write(command)
time.sleep(0.5)
# Read response byte by byte
response = b''
while True:
byte = ser.read(1)
if not byte:
break
response += byte
if byte == b'\n' or byte == b'\r':
break
print(f"\nRaw response (hex): {response.hex()}")
print(f"Raw response (ascii): {response}")
print(f"Response length: {len(response)} bytes")
# Check for common issues
if not response:
print("✗ No response - device may not be responding or wrong baud rate")
elif response == b'\r' or response == b'\n':
print("⚠ Only got line terminator - device may be echoing but not responding to command")
else:
print("✓ Got a response!")
ser.close()
return True
except Exception as e:
print(f"✗ Error: {e}")
return False
def test_echo(port, baudrate=9600):
"""Test if the device echoes commands back."""
print(f"\n{'='*60}")
print(f"Echo Test: {port} at {baudrate} baud")
print(f"{'='*60}")
try:
ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1.0
)
# Send a test character
test_char = b'T'
print(f"Sending test character: {test_char}")
ser.write(test_char)
time.sleep(0.1)
echo = ser.read(1)
if echo == test_char:
print(f"✓ Device echoes input")
elif echo:
print(f"⚠ Device sent something but not the same: {echo}")
else:
print(f"✗ No echo")
ser.close()
return True
except Exception as e:
print(f"✗ Error: {e}")
return False
def main():
"""Run diagnostic tests."""
port = "/dev/ttyUSB2"
if len(sys.argv) > 1:
port = sys.argv[1]
print(f"\n{'#'*60}")
print(f"# Helios Laser Serial Diagnostic Tool")
print(f"# Testing port: {port}")
print(f"{'#'*60}")
# Test standard baud rate
success, response = test_port(port, 9600)
if not success:
print("\n" + "="*60)
print("Standard baud rate (9600) failed. Trying alternatives...")
print("="*60)
# Try other common baud rates
for baudrate in [115200, 19200, 4800, 2400]:
success, response = test_port(port, baudrate)
if success:
print(f"\n✓ SUCCESS! Device responds at {baudrate} baud")
break
else:
print(f"\n✓ SUCCESS! Device responds at 9600 baud")
# Run additional diagnostics
print("\n")
test_raw_communication(port, 9600)
print("\n")
test_echo(port, 9600)
print(f"\n{'#'*60}")
print("# Diagnostic Tests Complete")
print(f"{'#'*60}\n")
if __name__ == "__main__":
main()
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""
Simple serial terminal for manual Helios laser testing.
Allows sending raw commands and viewing responses.
"""
import serial
import sys
from threading import Thread
import time
def read_from_port(ser):
"""Read data from serial port and display it."""
while True:
try:
if ser.in_waiting:
data = ser.read(ser.in_waiting)
print(f"\n[RX] {data.decode('ascii', errors='replace')}", end='')
sys.stdout.flush()
except:
break
time.sleep(0.01)
def main():
"""Run interactive serial terminal."""
port = "/dev/ttyUSB2"
if len(sys.argv) > 1:
port = sys.argv[1]
try:
ser = serial.Serial(
port=port,
baudrate=9600,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.1
)
print(f"Connected to {port} at 9600 baud")
print("Type commands and press Enter. Type 'quit' to exit.\n")
# Start reader thread
reader_thread = Thread(target=read_from_port, args=(ser,), daemon=True)
reader_thread.start()
while True:
try:
user_input = input("[TX] ")
if user_input.lower() == 'quit':
break
# Send command with carriage return
command = user_input + '\r'
ser.write(command.encode('ascii'))
time.sleep(0.1)
except KeyboardInterrupt:
break
except Exception as e:
print(f"Error: {e}")
ser.close()
print("\nDisconnected")
except Exception as e:
print(f"Failed to open {port}: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
-395
View File
@@ -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=60.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'))
-2635
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
"""Scan planning and modeling modules"""
from .sc3_scan_model import SC3ScanModel
from .stage_scan_plan_generator import *
-638
View File
@@ -1,638 +0,0 @@
"""
Scan Model Class.
Holds the scan configuration, and computes the
required start and end points for various specified angles.
Python implementation of SC3ScanModel.cs
"""
import math
from decimal import Decimal, InvalidOperation
from typing import List, Optional
import csv
class SC3ScanModel:
"""
Scan Model for generating scan paths and rotated scans.
Manages scan configuration and computes scan coordinates at various angles.
"""
def __init__(self):
# Private variables
self._x_origin: Decimal = Decimal('0.0')
self._y_origin: Decimal = Decimal('0.0')
self._x_delta: Decimal = Decimal('0.0')
self._y_delta: Decimal = Decimal('0.0')
self._row_spacing: Decimal = Decimal('0.0')
self._laser_frequency: Decimal = Decimal('2000.0') # Default: 2000 Hz
self._scan_velocity: Decimal = Decimal('100.0') # Default: 100 mm/s
self._scan_acceleration: Decimal = Decimal('0.0')
self._scan_angles: int = 0
self._points_required: int = 0
self._rows_required: int = 0
self._points_per_line: int = 0
# Optical axis centerline in stage coordinates
# Stage: MLS203-1
self._optical_x_origin: Decimal = Decimal('55.0')
self._optical_y_origin: Decimal = Decimal('37.5')
# Data storage
self._scan_coordinates: List[List[Decimal]] = []
self._scan_velocities: List[List[Decimal]] = []
self._scan_accelerations: List[List[Decimal]] = []
self._rotated_coordinates: List[List[List[Decimal]]] = []
# Constants
self._deg2rad: float = math.pi / 180.0
# Properties
@property
def x_origin(self) -> Decimal:
"""X coordinate of scan origin (mm)"""
return self._x_origin
@x_origin.setter
def x_origin(self, value: Decimal):
try:
self._x_origin = Decimal(str(value))
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid x_origin value: {value}") from e
@property
def y_origin(self) -> Decimal:
"""Y coordinate of scan origin (mm)"""
return self._y_origin
@y_origin.setter
def y_origin(self, value: Decimal):
try:
self._y_origin = Decimal(str(value))
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid y_origin value: {value}") from e
@property
def x_delta(self) -> Decimal:
"""Total X distance to scan (mm)"""
return self._x_delta
@x_delta.setter
def x_delta(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("x_delta must be non-negative")
self._x_delta = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid x_delta value: {value}") from e
@property
def y_delta(self) -> Decimal:
"""Total Y distance to scan (mm)"""
return self._y_delta
@y_delta.setter
def y_delta(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("y_delta must be non-negative")
self._y_delta = val
self.calculate_rows_required()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid y_delta value: {value}") from e
@property
def row_spacing(self) -> Decimal:
"""Spacing between scan rows (mm)"""
return self._row_spacing
@row_spacing.setter
def row_spacing(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("row_spacing must be non-negative")
self._row_spacing = val
self.calculate_rows_required()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid row_spacing value: {value}") from e
@property
def laser_frequency(self) -> Decimal:
"""Laser pulse frequency (Hz)"""
return self._laser_frequency
@laser_frequency.setter
def laser_frequency(self, value: Decimal):
try:
val = Decimal(str(value))
if val <= 0:
raise ValueError("laser_frequency must be positive")
self._laser_frequency = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid laser_frequency value: {value}") from e
@property
def scan_velocity(self) -> Decimal:
"""Scan velocity (mm/s)"""
return self._scan_velocity
@scan_velocity.setter
def scan_velocity(self, value: Decimal):
try:
val = Decimal(str(value))
if val <= 0:
raise ValueError("scan_velocity must be positive")
self._scan_velocity = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid scan_velocity value: {value}") from e
@property
def scan_acceleration(self) -> Decimal:
"""Scan acceleration (mm/s²)"""
return self._scan_acceleration
@scan_acceleration.setter
def scan_acceleration(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("scan_acceleration must be non-negative")
self._scan_acceleration = val
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid scan_acceleration value: {value}") from e
@property
def scan_angles(self) -> int:
"""Number of scan angles to compute"""
return self._scan_angles
@scan_angles.setter
def scan_angles(self, value: int):
if value < 0:
raise ValueError("scan_angles must be non-negative")
self._scan_angles = value
# Only compute rotated scans if we have base scan coordinates
if self._scan_coordinates:
self.compute_rotated_scans()
@property
def points_required(self) -> int:
"""Total number of points required for a single angle scan (computed)"""
return self._points_required
@property
def rows_required(self) -> int:
"""Number of rows required for the scan (computed)"""
return self._rows_required
@property
def points_per_line(self) -> int:
"""Number of points per scan line (computed)"""
return self._points_per_line
@property
def scan_coordinates(self) -> List[List[Decimal]]:
"""List of scan coordinates [x_start, y_start, x_end, y_end] in mm"""
return self._scan_coordinates
@property
def scan_velocities(self) -> List[List[Decimal]]:
"""List of velocity vectors [vx, vy] in mm/s for each angle"""
return self._scan_velocities
@property
def scan_accelerations(self) -> List[List[Decimal]]:
"""List of acceleration vectors [ax, ay] in mm/s² for each angle"""
return self._scan_accelerations
@property
def rotated_coordinates(self) -> List[List[List[Decimal]]]:
"""List of rotated scan coordinates for each angle in mm"""
return self._rotated_coordinates
@property
def optical_x_origin(self) -> Decimal:
"""X coordinate of optical axis origin in mm (read-only, MLS203-1 stage)"""
return self._optical_x_origin
@property
def optical_y_origin(self) -> Decimal:
"""Y coordinate of optical axis origin in mm (read-only, MLS203-1 stage)"""
return self._optical_y_origin
# Calculation Methods
def calculate_points_per_line(self):
"""
Calculates the number of data points per scan line based on
x_delta, scan_velocity, and laser_frequency.
Formula: points = (distance / velocity) * frequency
"""
if self._scan_velocity != 0:
self._points_per_line = int(
(self._x_delta / self._scan_velocity) * self._laser_frequency
)
def calculate_points_required(self):
"""
Calculates the total number of data points for a complete single-angle scan.
Also triggers computation of the zero-angle scan coordinates.
Formula: total_points = points_per_line * rows_required
"""
if self._rows_required != 0:
self._points_required = self._points_per_line * self._rows_required
self.compute_zero_scan()
def calculate_rows_required(self):
"""
Calculates the number of scan rows needed based on y_delta and row_spacing.
Formula: rows = ceil(y_delta / row_spacing)
"""
if self._row_spacing == 0:
self._rows_required = 0
else:
self._rows_required = int(math.ceil(self._y_delta / self._row_spacing))
def compute_zero_scan(self):
"""
Computes the zero-angle (reference) scan coordinates.
Each coordinate is [x_start, y_start, x_end, y_end].
"""
# Ignore the zero-row case
if self._rows_required == 0:
return
y_offset = Decimal('0.0')
self._scan_coordinates.clear()
for row in range(self._rows_required + 1):
# Calculate x/y origin/delta for each needed row
y_offset = Decimal(row) * self._row_spacing
coords = [
self._x_origin, # x_start
self._y_origin + y_offset, # y_start
self._x_origin + self._x_delta, # x_end
self._y_origin + y_offset # y_end (same as y_start for horizontal scan)
]
self._scan_coordinates.append(coords)
def _rotate_point(self, x: Decimal, y: Decimal, cosine: Decimal, sine: Decimal) -> tuple:
"""
Rotate a point around the optical axis origin.
Args:
x, y: Point coordinates to rotate
cosine, sine: Precomputed cos and sin of rotation angle
Returns:
Tuple of (rotated_x, rotated_y)
"""
# Rotation transformation:
# Xr = (X - Xo)*cos(a) + (Y - Yo)*sin(a) + Xo
# Yr = -(X - Xo)*sin(a) + (Y - Yo)*cos(a) + Yo
x_rot = ((x - self._optical_x_origin) * cosine +
(y - self._optical_y_origin) * sine +
self._optical_x_origin)
y_rot = (-(x - self._optical_x_origin) * sine +
(y - self._optical_y_origin) * cosine +
self._optical_y_origin)
return (x_rot, y_rot)
def _compute_rotated_aoi_bbox(self, angle_rad: float) -> tuple:
"""
Compute the bounding box of the rotated area of interest.
Rotates the four corners of the AoI rectangle and finds the
min/max extents to create a bounding box.
Args:
angle_rad: Rotation angle in radians
Returns:
Tuple of (min_x, min_y, max_x, max_y) as Decimals
"""
cosine = Decimal(str(math.cos(angle_rad)))
sine = Decimal(str(math.sin(angle_rad)))
# Define the four corners of the AoI rectangle
corners = [
(self._x_origin, self._y_origin),
(self._x_origin + self._x_delta, self._y_origin),
(self._x_origin + self._x_delta, self._y_origin + self._y_delta),
(self._x_origin, self._y_origin + self._y_delta)
]
# Rotate all corners
rotated_corners = []
for x, y in corners:
x_rot, y_rot = self._rotate_point(x, y, cosine, sine)
rotated_corners.append((x_rot, y_rot))
# Find bounding box extents
x_coords = [corner[0] for corner in rotated_corners]
y_coords = [corner[1] for corner in rotated_corners]
return (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
def compute_rotated_scans(self):
"""
Computes rotated scan coordinates by rotating the area of interest (AoI)
and generating horizontal (+x direction) scans through the bounding box
of the rotated AoI.
The rotation covers 0 to 180 degrees with spacing determined by scan_angles.
For each angle:
1. Rotate the AoI rectangle around the optical axis origin
2. Compute the bounding box of the rotated rectangle
3. Generate horizontal scan lines through the bounding box
"""
if self._rows_required == 0:
return
# Compute spacing between scans
if self._scan_angles == 0:
angle_spacing = 180
else:
angle_spacing = 180 / self._scan_angles
self._rotated_coordinates.clear()
# Iterate through each required angle from 0 to 180 degrees
i = 0
while i < 180:
current_angle_radians = i * (math.pi / 180.0)
# Get bounding box of rotated AoI
min_x, min_y, max_x, max_y = self._compute_rotated_aoi_bbox(current_angle_radians)
# Calculate the y extent of the bounding box
y_extent = max_y - min_y
# Determine number of rows needed for this bounding box
if self._row_spacing == 0:
num_rows = 0
else:
num_rows = int(math.ceil(y_extent / self._row_spacing))
temp_list = []
# Generate horizontal scan lines through the bounding box
for row in range(num_rows + 1):
y_offset = Decimal(row) * self._row_spacing
y_pos = min_y + y_offset
# Create horizontal scan line at this y position
scan_line = [
min_x, # x_start
y_pos, # y_start
max_x, # x_end
y_pos # y_end (same as y_start for horizontal scan)
]
temp_list.append(scan_line)
self._rotated_coordinates.append(temp_list)
i += int(round(angle_spacing))
def compute_kinematics(self, offset: int = 0):
"""
Computes velocity and acceleration component vectors for each scan angle.
For each angle, decomposes the scalar velocity and acceleration into
X and Y components based on the scan direction angle.
Args:
offset: Angle offset in degrees (default: 0)
"""
if self._scan_angles == 0:
scan_increment = 180
else:
scan_increment = 180 // self._scan_angles
self._scan_velocities.clear()
self._scan_accelerations.clear()
for i in range(self._scan_angles):
deg_angle = scan_increment * i + offset
angle_rad = deg_angle * self._deg2rad
# Compute velocity components: V = V_mag * [cos(θ), sin(θ)]
velocities = [
Decimal(str(math.cos(angle_rad))) * self._scan_velocity,
Decimal(str(math.sin(angle_rad))) * self._scan_velocity
]
# Compute acceleration components: A = A_mag * [cos(θ), sin(θ)]
accels = [
Decimal(str(math.cos(angle_rad))) * self._scan_acceleration,
Decimal(str(math.sin(angle_rad))) * self._scan_acceleration
]
self._scan_velocities.append(velocities)
self._scan_accelerations.append(accels)
# Export Methods
def export_zero_scan_csv(self, filename: Optional[str] = None) -> str:
"""
Export the zero-angle scan coordinates to a CSV file.
Args:
filename: Output filename. If None, generates from row count.
Returns:
The filename that was written
Raises:
ValueError: If no scan coordinates have been computed
IOError: If file cannot be written
"""
if not self._scan_coordinates:
raise ValueError("No scan coordinates available. Configure scan parameters first.")
if filename is None:
filename = f"scantest-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._scan_coordinates:
writer.writerow([str(c) for c in coords])
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
def export_rotated_scan_csv(self, angle_index: int, filename: Optional[str] = None) -> str:
"""
Export a specific rotated scan to CSV.
Args:
angle_index: Index of the angle to export (0-based)
filename: Output filename. If None, generates from angle and row count.
Returns:
The filename that was written
Raises:
ValueError: If angle_index is invalid or no rotated coordinates exist
IOError: If file cannot be written
"""
if not self._rotated_coordinates:
raise ValueError("No rotated coordinates available. Set scan_angles first.")
if angle_index < 0 or angle_index >= len(self._rotated_coordinates):
raise ValueError(
f"Invalid angle_index {angle_index}. Must be 0-{len(self._rotated_coordinates)-1}"
)
if filename is None:
angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180)
filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._rotated_coordinates[angle_index]:
writer.writerow([str(c) for c in coords])
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
def export_all_rotated_scans_csv(self, output_dir: str = ".") -> List[str]:
"""
Export all rotated scans to separate CSV files.
Args:
output_dir: Directory to write files to (default: current directory)
Returns:
List of filenames that were written
Raises:
ValueError: If no rotated coordinates exist
IOError: If files cannot be written
"""
if not self._rotated_coordinates:
raise ValueError("No rotated coordinates available. Set scan_angles first.")
import os
filenames = []
for angle_index in range(len(self._rotated_coordinates)):
angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180)
filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv"
filepath = os.path.join(output_dir, filename)
try:
with open(filepath, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._rotated_coordinates[angle_index]:
writer.writerow([str(c) for c in coords])
filenames.append(filepath)
except IOError as e:
raise IOError(f"Failed to write file {filepath}: {e}") from e
return filenames
def export_kinematics_csv(self, filename: Optional[str] = None) -> str:
"""
Export velocity and acceleration data to CSV.
Format: vx, vy, ax, ay for each angle.
Args:
filename: Output filename. If None, generates from row count.
Returns:
The filename that was written
Raises:
ValueError: If no kinematics data has been computed
IOError: If file cannot be written
"""
if not self._scan_velocities or not self._scan_accelerations:
raise ValueError("No kinematics data available. Call compute_kinematics() first.")
if filename is None:
filename = f"kinematics-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
# Optional: write header
writer.writerow(['vx', 'vy', 'ax', 'ay'])
for i in range(len(self._scan_velocities)):
row = [
str(self._scan_velocities[i][0]),
str(self._scan_velocities[i][1]),
str(self._scan_accelerations[i][0]),
str(self._scan_accelerations[i][1])
]
writer.writerow(row)
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
# Utility Methods
def get_angle_list(self) -> List[int]:
"""
Get the list of scan angles in degrees.
Returns:
List of angles in degrees for the configured scan_angles
"""
if self._scan_angles == 0:
return []
scan_increment = 180 // self._scan_angles
return [scan_increment * i for i in range(self._scan_angles)]
def get_scan_info(self) -> dict:
"""
Get a dictionary with current scan configuration and computed values.
Returns:
Dictionary containing scan parameters and computed values
"""
return {
'x_origin': float(self._x_origin),
'y_origin': float(self._y_origin),
'x_delta': float(self._x_delta),
'y_delta': float(self._y_delta),
'row_spacing': float(self._row_spacing),
'laser_frequency': float(self._laser_frequency),
'scan_velocity': float(self._scan_velocity),
'scan_acceleration': float(self._scan_acceleration),
'scan_angles': self._scan_angles,
'points_per_line': self._points_per_line,
'rows_required': self._rows_required,
'points_required': self._points_required,
'optical_x_origin': float(self._optical_x_origin),
'optical_y_origin': float(self._optical_y_origin),
'num_scan_coordinates': len(self._scan_coordinates),
'num_rotated_angles': len(self._rotated_coordinates),
'angle_list': self.get_angle_list(),
}
def __repr__(self) -> str:
"""String representation of the scan model."""
return (
f"SC3ScanModel("
f"origin=({self._x_origin},{self._y_origin}), "
f"delta=({self._x_delta},{self._y_delta}), "
f"rows={self._rows_required}, "
f"angles={self._scan_angles})"
)
-117
View File
@@ -1,117 +0,0 @@
"""
This module contains a StageScanPlanGenerator class that generates scanning plans
for microscope stages. The scans are generated in a single direction based on provided
start and end coordinates, as well as spacing between scan lines.
"""
import numpy as np
from typing import List, Tuple, Optional
class StageScanPlanGenerator:
"""
A class to generate scanning plans for microscope stages.
Attributes:
start_coords (Tuple[float, float]): Starting X and Y coordinates.
end_coords (Tuple[float, float]): Ending X and Y coordinates.
spacing (float): Spacing between scan lines in the perpendicular direction.
"""
def __init__(self, start_x: float, start_y: float,
end_x: float, end_y: float, spacing: float):
"""
Initialize the StageScanPlanGenerator with scan parameters.
Args:
start_x (float): Starting X coordinate.
start_y (float): Starting Y coordinate.
end_x (float): Ending X coordinate.
end_y (float): Ending Y coordinate.
spacing (float): Spacing between scan lines in the perpendicular direction.
"""
self.start_coords = (start_x, start_y)
self.end_coords = (end_x, end_y)
self.spacing = spacing
def _calculate_scan_direction(self) -> Tuple[float, float]:
"""
Calculate the direction vector of the scan.
Returns:
Tuple[float, float]: Normalized direction vector (dx, dy).
"""
dx = self.end_coords[0] - self.start_coords[0]
dy = self.end_coords[1] - self.start_coords[1]
length = np.sqrt(dx**2 + dy**2)
if length == 0:
raise ValueError("Start and end coordinates cannot be the same")
return dx / length, dy / length
def _calculate_perpendicular_direction(self) -> Tuple[float, float]:
"""
Calculate a perpendicular direction vector to the scan direction.
Returns:
Tuple[float, float]: Perpendicular vector (px, py).
"""
dx, dy = self._calculate_scan_direction()
# Rotate (dx, dy) by 90 degrees to get perpendicular vector
px = -dy
py = dx
return px, py
def generate_scan_plan(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
"""
Generate a scan plan with waypoints for the microscope stage.
Returns:
List[Tuple[Tuple[float, float], Tuple[float, float]]]:
A list of (start_point, end_point) tuples for each scan line.
"""
dx, dy = self._calculate_scan_direction()
px, py = self._calculate_perpendicular_direction()
# Calculate the total length in the perpendicular direction
start_x, start_y = self.start_coords
end_x, end_y = self.end_coords
min_coord_perp = min(start_x * px + start_y * py, end_x * px + end_y * py)
max_coord_perp = max(start_x * px + start_y * py, end_x * px + end_y * py)
# Generate scan lines
waypoints = []
current_pos_perp = min_coord_perp
while current_pos_perp <= max_coord_perp:
# Calculate start and end points for this scan line
perp_offset = current_pos_perp - (start_x * px + start_y * py)
line_start_x = start_x + perp_offset * dx
line_start_y = start_y + perp_offset * dy
line_end_x = line_start_x + dx * abs(self.end_coords[0] - self.start_coords[0])
line_end_y = line_start_y + dy * abs(self.end_coords[1] - self.start_coords[1])
waypoints.append(((line_start_x, line_start_y), (line_end_x, line_end_y)))
current_pos_perp += self.spacing
return waypoints
# Example usage:
if __name__ == "__main__":
# Create a scan plan generator
generator = StageScanPlanGenerator(
start_x=0.0, start_y=0.0,
end_x=10.0, end_y=10.0,
spacing=2.5
)
# Generate the scan plan
scan_plan = generator.generate_scan_plan()
# Print the scan plan
print("Scan Plan:")
for i, (start, end) in enumerate(scan_plan):
print(f"Line {i+1}: Start at {start}, End at {end}")
-1321
View File
File diff suppressed because it is too large Load Diff