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:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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).
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
"""Scan planning and modeling modules"""
|
||||
from .sc3_scan_model import SC3ScanModel
|
||||
from .stage_scan_plan_generator import *
|
||||
@@ -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})"
|
||||
)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user