Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bcdff9756 | |||
| 2041fc8439 | |||
| 1014533626 |
Regular → Executable
-37
@@ -1,37 +0,0 @@
|
|||||||
# ADC YOFF Sign Bug — sras_viewer.py
|
|
||||||
|
|
||||||
## Status
|
|
||||||
Fix applied, awaiting user testing.
|
|
||||||
|
|
||||||
## What was wrong
|
|
||||||
|
|
||||||
`DC_YOFF_ADC` in `sras_viewer.py` was `+87.04` instead of `-87.04`.
|
|
||||||
|
|
||||||
The Tektronix scope stores CH3/CH4 waveform data as **signed int8** (−128 to +127), where ADC 0 = screen center. The scope's vertical position for CH3/CH4 is set to `−2.72 div` in `sc3_aui_app.py`, which places 0 V **below** center at ADC count `−2.72 × 32 = −87.04`. The comment in the code had the formula as `-position × (256/8)` (sign flipped), producing `+87.04` instead of the correct `−87.04`.
|
|
||||||
|
|
||||||
## Effect of the bug
|
|
||||||
|
|
||||||
- `adc_to_mv` was off by 272 mV in the negative direction
|
|
||||||
- ADC −87 (true 0 V signal) → −272 mV (should be ≈ 0 mV)
|
|
||||||
- ADC 0 (screen center, above ground) → −136 mV (should be +136 mV)
|
|
||||||
- DC images for CH3/CH4 (Bias A/B) showed large negative voltages, physically impossible for DC bias signals
|
|
||||||
- RF mask threshold (`mv_to_adc`) was also broken: threshold ADC value ~+87 was being compared against pixel means clustered around −87, so nearly every pixel would have been incorrectly masked
|
|
||||||
|
|
||||||
## The fix
|
|
||||||
|
|
||||||
`sras_viewer.py` line 48:
|
|
||||||
```python
|
|
||||||
# Before
|
|
||||||
DC_YOFF_ADC = 87.04 # ADC count that represents 0 V
|
|
||||||
|
|
||||||
# After
|
|
||||||
DC_YOFF_ADC = -87.04 # ADC count that represents 0 V
|
|
||||||
```
|
|
||||||
Comment on line 47 also corrected from `-position × (256/8)` to `position × (256/8)`.
|
|
||||||
|
|
||||||
## What to verify during testing
|
|
||||||
|
|
||||||
1. CH3 and CH4 DC images show positive (or near-zero) voltages consistent with the bias signal levels
|
|
||||||
2. RF (CH1) image is not excessively masked — pixels with a genuine bias signal above the threshold should appear
|
|
||||||
3. `mv_to_adc(0.0)` should now return −87.04 (not +87.04)
|
|
||||||
4. The default threshold of 0.125 mV should correspond to ADC ≈ −87.0, not +87.1
|
|
||||||
@@ -14,9 +14,11 @@ from hardware.coherent_hops_laser import CoherentHOPSLaser, DummyLaser
|
|||||||
from hardware.helios_laser import HeliosLaser, PulseMode
|
from hardware.helios_laser import HeliosLaser, PulseMode
|
||||||
from hardware.uc480_camera import UC480Camera, CameraStreamThread
|
from hardware.uc480_camera import UC480Camera, CameraStreamThread
|
||||||
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y, TriggerBitsServo
|
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y, TriggerBitsServo
|
||||||
|
from hardware.t3r_driver import T3RDriver
|
||||||
from motion_worker import MotionWorker
|
from motion_worker import MotionWorker
|
||||||
from scanning.stage_scan_plan_generator import StageScanPlanGenerator
|
from scanning.stage_scan_plan_generator import StageScanPlanGenerator
|
||||||
from genesis_worker import GenesisWorker, GenesisCommand
|
from genesis_worker import GenesisWorker, GenesisCommand
|
||||||
|
from t3r_control_panel import T3RControlPanel
|
||||||
from ui_mainwindow import Ui_MainWindow
|
from ui_mainwindow import Ui_MainWindow
|
||||||
|
|
||||||
# Page indices in stackedWidget
|
# Page indices in stackedWidget
|
||||||
@@ -95,10 +97,11 @@ class ScanWorker(QtCore.QObject):
|
|||||||
overall_progress = QtCore.pyqtSignal(int)
|
overall_progress = QtCore.pyqtSignal(int)
|
||||||
status_message = QtCore.pyqtSignal(str)
|
status_message = QtCore.pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, scan_params, motion_worker):
|
def __init__(self, scan_params, motion_worker, t3r_driver=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.scan_params = scan_params
|
self.scan_params = scan_params
|
||||||
self.motion_worker = motion_worker
|
self.motion_worker = motion_worker
|
||||||
|
self.t3r_driver = t3r_driver
|
||||||
self.should_stop = False
|
self.should_stop = False
|
||||||
|
|
||||||
@QtCore.pyqtSlot()
|
@QtCore.pyqtSlot()
|
||||||
@@ -108,7 +111,30 @@ class ScanWorker(QtCore.QObject):
|
|||||||
self.motion_worker.scanning_active = True
|
self.motion_worker.scanning_active = True
|
||||||
try:
|
try:
|
||||||
self.scan_started.emit()
|
self.scan_started.emit()
|
||||||
# TODO: implement scan execution logic
|
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()
|
self.scan_completed.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.scan_failed.emit(str(e))
|
self.scan_failed.emit(str(e))
|
||||||
@@ -140,8 +166,13 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
self.scan_worker: Optional[ScanWorker] = None
|
self.scan_worker: Optional[ScanWorker] = None
|
||||||
self.scan_thread: Optional[QtCore.QThread] = 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._connect_signals()
|
||||||
self._init_genesis_worker()
|
self._init_genesis_worker()
|
||||||
|
self._init_t3r_menu()
|
||||||
self.ui.stackedWidget.setCurrentIndex(PAGE_START)
|
self.ui.stackedWidget.setCurrentIndex(PAGE_START)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -358,7 +389,13 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
pass # TODO
|
pass # TODO
|
||||||
|
|
||||||
def _on_t3r_connect(self):
|
def _on_t3r_connect(self):
|
||||||
pass # TODO
|
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
|
# New scan page
|
||||||
@@ -419,6 +456,10 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
"save_directory": self.ui.newscan_save_directory_edit.text(),
|
"save_directory": self.ui.newscan_save_directory_edit.text(),
|
||||||
"scan_velocity_mm_s": self.config["stage"]["scan_velocity_mm_s"],
|
"scan_velocity_mm_s": self.config["stage"]["scan_velocity_mm_s"],
|
||||||
"scan_acceleration_mm_s2": self.config["stage"]["scan_acceleration_mm_s2"],
|
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -456,7 +497,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
|
|
||||||
def _start_scan(self, scan_params: dict):
|
def _start_scan(self, scan_params: dict):
|
||||||
self.scan_thread = QtCore.QThread()
|
self.scan_thread = QtCore.QThread()
|
||||||
self.scan_worker = ScanWorker(scan_params, self.motion_worker)
|
self.scan_worker = ScanWorker(scan_params, self.motion_worker, self.t3r_driver)
|
||||||
self.scan_worker.moveToThread(self.scan_thread)
|
self.scan_worker.moveToThread(self.scan_thread)
|
||||||
|
|
||||||
self.scan_thread.started.connect(self.scan_worker.run_scan)
|
self.scan_thread.started.connect(self.scan_worker.run_scan)
|
||||||
@@ -497,6 +538,33 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
self.scan_thread = None
|
self.scan_thread = None
|
||||||
self.scan_worker = 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
|
# Genesis laser worker
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -523,6 +591,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
|
if self.t3r_driver.is_open:
|
||||||
|
self.t3r_driver.disconnect()
|
||||||
self._cleanup_genesis_worker()
|
self._cleanup_genesis_worker()
|
||||||
self._cleanup_scan_thread()
|
self._cleanup_scan_thread()
|
||||||
if self.motion_thread:
|
if self.motion_thread:
|
||||||
|
|||||||
Regular → Executable
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"t3r_port": "/dev/ttyACM0",
|
"t3r_port": "/dev/ttyACM0",
|
||||||
"bbd_port": "/dev/ttyAPT",
|
"bbd_port": "/dev/ttyUSB0",
|
||||||
"oscope_ip": "192.168.100.105",
|
"oscope_ip": "192.168.100.105",
|
||||||
"laser_freq_hz": 20000.0,
|
"laser_freq_hz": 20000.0,
|
||||||
"save_dir": "/opt/scanengine-3/scans",
|
"save_dir": "/data/SRAS",
|
||||||
"helios_port": "/dev/ttyUSB0"
|
"helios_port": "/dev/ttyUSB1"
|
||||||
}
|
}
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
@@ -1,5 +1,6 @@
|
|||||||
"""Hardware driver modules for ScanEngine-3"""
|
"""Hardware driver modules for ScanEngine-3"""
|
||||||
from .pybbd202 import ThorlabsServoDriver, TriggerBitsServo, AXIS_X, AXIS_Y, CONTROLLER
|
from .pybbd202 import ThorlabsServoDriver, TriggerBitsServo, AXIS_X, AXIS_Y, CONTROLLER
|
||||||
|
from .t3r_driver import T3RDriver
|
||||||
from .uc480_camera import *
|
from .uc480_camera import *
|
||||||
from .tektronix_base import *
|
from .tektronix_base import *
|
||||||
from .coherent_hops_laser import *
|
from .coherent_hops_laser import *
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Executable
+287
@@ -0,0 +1,287 @@
|
|||||||
|
"""T3R Stepper Controller driver for ScanEngine-3.
|
||||||
|
|
||||||
|
Qt-based driver that owns the serial connection and an internal reader QThread.
|
||||||
|
All events arrive as Qt signals; all commands are fire-and-forget writes.
|
||||||
|
Create in the main (GUI) thread; no additional thread management required.
|
||||||
|
|
||||||
|
Gear train (stage rotation via GR-axis, ch3):
|
||||||
|
Motor → 10T pinion → 30T idler → 125T index gear (stage)
|
||||||
|
Ratio motor:stage = 125/10 = 12.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import serial
|
||||||
|
from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal
|
||||||
|
|
||||||
|
from . import t3r_protocol as proto
|
||||||
|
|
||||||
|
|
||||||
|
class _T3RReader(QThread):
|
||||||
|
"""Blocking read loop — runs on its own QThread."""
|
||||||
|
|
||||||
|
frame = pyqtSignal(int, bytes) # (cmd, payload) for each valid frame
|
||||||
|
finished_reason = pyqtSignal(str) # "" = clean stop, else I/O error string
|
||||||
|
|
||||||
|
def __init__(self, ser: serial.Serial):
|
||||||
|
super().__init__()
|
||||||
|
self._ser = ser
|
||||||
|
self._running = True
|
||||||
|
self._parser = proto.FrameParser()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
reason = ""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
data = self._ser.read(256)
|
||||||
|
except Exception as exc:
|
||||||
|
reason = str(exc) or exc.__class__.__name__
|
||||||
|
break
|
||||||
|
if data:
|
||||||
|
for cmd, payload in self._parser.feed(data):
|
||||||
|
self.frame.emit(cmd, payload)
|
||||||
|
self.finished_reason.emit(reason)
|
||||||
|
|
||||||
|
|
||||||
|
class T3RDriver(QObject):
|
||||||
|
"""Qt-based driver for the T3R four-channel stepper controller.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
driver = T3RDriver()
|
||||||
|
driver.handshake_ok.connect(lambda pv, fw, nc: print("connected"))
|
||||||
|
driver.info_updated.connect(on_info)
|
||||||
|
driver.connect("/dev/ttyUSB0")
|
||||||
|
driver.move(0, steps=3200, velocity=8000, accel=4000)
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHANNEL_NAMES = ["T-axis (focus)", "Axis 1", "Axis 2", "GR-axis"]
|
||||||
|
GR_AXIS_CH = 3
|
||||||
|
|
||||||
|
# Gear train constants
|
||||||
|
MOTOR_FULL_STEPS_PER_REV = 200
|
||||||
|
GEAR_TEETH_MOTOR = 10
|
||||||
|
GEAR_TEETH_STAGE = 125 # idler is 30T but does not change ratio
|
||||||
|
|
||||||
|
# ── Signals ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
port_opened = pyqtSignal() # serial port open; PING sent
|
||||||
|
handshake_ok = pyqtSignal(int, int, int) # proto_ver, fw_ver, num_channels
|
||||||
|
disconnected = pyqtSignal(str) # reason ("" = user-initiated)
|
||||||
|
|
||||||
|
info_updated = pyqtSignal(int, object) # ch, proto.Info
|
||||||
|
drv_status_updated = pyqtSignal(int, object) # ch, proto.DrvStatus
|
||||||
|
position_updated = pyqtSignal(int, int) # ch, position (microsteps)
|
||||||
|
motion_done = pyqtSignal(int, int) # ch, final_position
|
||||||
|
stopped = pyqtSignal(int, int) # ch, final_position
|
||||||
|
fault_occurred = pyqtSignal(int, int) # ch, fault_mask
|
||||||
|
ack_received = pyqtSignal(int, int) # req_cmd, status (0=OK)
|
||||||
|
frame_received = pyqtSignal(int, bytes) # raw (cmd, payload) for log
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._ser: serial.Serial | None = None
|
||||||
|
self._reader: _T3RReader | None = None
|
||||||
|
self._write_lock = threading.Lock()
|
||||||
|
self._tearing_down = False
|
||||||
|
self._is_open = False
|
||||||
|
|
||||||
|
self._poll_timer = QTimer(self)
|
||||||
|
self._poll_timer.setInterval(250)
|
||||||
|
self._poll_timer.timeout.connect(self._poll)
|
||||||
|
|
||||||
|
# ── Connection ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self._is_open
|
||||||
|
|
||||||
|
def connect(self, port: str, baud: int = 115200) -> None:
|
||||||
|
"""Open the serial port and start the reader. Emits port_opened on success."""
|
||||||
|
if self._is_open:
|
||||||
|
self.disconnect()
|
||||||
|
try:
|
||||||
|
self._ser = serial.Serial(port, baudrate=baud, timeout=0.05)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"Cannot open {port}: {exc}") from exc
|
||||||
|
|
||||||
|
self._tearing_down = False
|
||||||
|
self._is_open = True
|
||||||
|
self._reader = _T3RReader(self._ser)
|
||||||
|
self._reader.frame.connect(self._on_frame)
|
||||||
|
self._reader.finished_reason.connect(self._on_reader_finished)
|
||||||
|
self._reader.start()
|
||||||
|
self.port_opened.emit()
|
||||||
|
self.send_frame(proto.ping()) # handshake; polling starts on PONG
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""Close port and stop polling."""
|
||||||
|
self._teardown("")
|
||||||
|
|
||||||
|
def _on_reader_finished(self, reason: str):
|
||||||
|
if reason:
|
||||||
|
self._teardown(reason)
|
||||||
|
|
||||||
|
def _teardown(self, reason: str):
|
||||||
|
if self._tearing_down or not self._is_open:
|
||||||
|
return
|
||||||
|
self._tearing_down = True
|
||||||
|
self._poll_timer.stop()
|
||||||
|
self._is_open = False
|
||||||
|
|
||||||
|
reader, self._reader = self._reader, None
|
||||||
|
ser, self._ser = self._ser, None
|
||||||
|
|
||||||
|
if reader is not None:
|
||||||
|
reader.stop()
|
||||||
|
if QThread.currentThread() is not reader:
|
||||||
|
reader.wait(1000)
|
||||||
|
if ser is not None:
|
||||||
|
try:
|
||||||
|
ser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.disconnected.emit(reason)
|
||||||
|
|
||||||
|
# ── Sending ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_frame(self, frame: bytes) -> bool:
|
||||||
|
if not self._is_open or self._ser is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with self._write_lock:
|
||||||
|
self._ser.write(frame)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
self._teardown(str(exc) or exc.__class__.__name__)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Command API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def ping(self):
|
||||||
|
self.send_frame(proto.ping())
|
||||||
|
|
||||||
|
def stop_all(self):
|
||||||
|
self.send_frame(proto.stop_all())
|
||||||
|
|
||||||
|
def enable(self, ch: int):
|
||||||
|
self.send_frame(proto.enable(ch))
|
||||||
|
|
||||||
|
def disable(self, ch: int):
|
||||||
|
self.send_frame(proto.disable(ch))
|
||||||
|
|
||||||
|
def enable_mask(self, mask: int):
|
||||||
|
self.send_frame(proto.enable_mask(mask))
|
||||||
|
|
||||||
|
def set_microstep(self, ch: int, microsteps: int):
|
||||||
|
self.send_frame(proto.set_microstep(ch, microsteps))
|
||||||
|
|
||||||
|
def set_current(self, ch: int, run_ma: int, hold_ma: int, ihold_delay: int):
|
||||||
|
self.send_frame(proto.set_current(ch, run_ma, hold_ma, ihold_delay))
|
||||||
|
|
||||||
|
def move(self, ch: int, steps: int, velocity: int, accel: int):
|
||||||
|
self.send_frame(proto.move(ch, steps, velocity, accel))
|
||||||
|
|
||||||
|
def jog(self, ch: int, velocity: int, accel: int):
|
||||||
|
self.send_frame(proto.jog(ch, velocity, accel))
|
||||||
|
|
||||||
|
def stop(self, ch: int, hard: bool = False):
|
||||||
|
self.send_frame(proto.stop(ch, hard))
|
||||||
|
|
||||||
|
def move_group(self, mask: int, steps: int, velocity: int, accel: int):
|
||||||
|
self.send_frame(proto.move_group(mask, steps, velocity, accel))
|
||||||
|
|
||||||
|
def jog_group(self, mask: int, velocity: int, accel: int):
|
||||||
|
self.send_frame(proto.jog_group(mask, velocity, accel))
|
||||||
|
|
||||||
|
def get_info(self, ch: int):
|
||||||
|
self.send_frame(proto.get_info(ch))
|
||||||
|
|
||||||
|
def get_drv_status(self, ch: int):
|
||||||
|
self.send_frame(proto.get_drv_status(ch))
|
||||||
|
|
||||||
|
def get_position(self, ch: int):
|
||||||
|
self.send_frame(proto.get_position(ch))
|
||||||
|
|
||||||
|
def set_position(self, ch: int, position: int):
|
||||||
|
self.send_frame(proto.set_position(ch, position))
|
||||||
|
|
||||||
|
# ── Rotation helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def steps_for_angle(self, angle_deg: float, microsteps: int) -> int:
|
||||||
|
"""Compute GR-axis microsteps needed to rotate the stage by angle_deg."""
|
||||||
|
gear_ratio = self.GEAR_TEETH_STAGE / self.GEAR_TEETH_MOTOR
|
||||||
|
steps_per_stage_rev = self.MOTOR_FULL_STEPS_PER_REV * microsteps * gear_ratio
|
||||||
|
return round(steps_per_stage_rev * angle_deg / 360.0)
|
||||||
|
|
||||||
|
def rotate_stage(self, angle_deg: float, microsteps: int,
|
||||||
|
velocity: int = 8000, accel: int = 4000):
|
||||||
|
"""Move GR-axis by the number of steps that rotate the stage by angle_deg."""
|
||||||
|
steps = self.steps_for_angle(angle_deg, microsteps)
|
||||||
|
self.move(self.GR_AXIS_CH, steps, velocity, accel)
|
||||||
|
|
||||||
|
# ── Polling ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_polling(self):
|
||||||
|
self._poll_timer.start()
|
||||||
|
|
||||||
|
def stop_polling(self):
|
||||||
|
self._poll_timer.stop()
|
||||||
|
|
||||||
|
def _poll(self):
|
||||||
|
if not self._is_open:
|
||||||
|
return
|
||||||
|
for ch in range(proto.NUM_CHANNELS):
|
||||||
|
self.send_frame(proto.get_info(ch))
|
||||||
|
|
||||||
|
# ── Frame dispatcher ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_frame(self, cmd: int, payload: bytes):
|
||||||
|
self.frame_received.emit(cmd, payload)
|
||||||
|
|
||||||
|
if cmd == proto.RSP_PONG:
|
||||||
|
p = proto.decode_pong(payload)
|
||||||
|
if p:
|
||||||
|
self.handshake_ok.emit(p.proto_ver, p.fw_version, p.num_channels)
|
||||||
|
self.start_polling()
|
||||||
|
|
||||||
|
elif cmd == proto.RSP_ACK:
|
||||||
|
a = proto.decode_ack(payload)
|
||||||
|
if a:
|
||||||
|
self.ack_received.emit(a.req_cmd, a.status)
|
||||||
|
|
||||||
|
elif cmd == proto.RSP_INFO:
|
||||||
|
info = proto.decode_info(payload)
|
||||||
|
if info and 0 <= info.ch < proto.NUM_CHANNELS:
|
||||||
|
self.info_updated.emit(info.ch, info)
|
||||||
|
|
||||||
|
elif cmd == proto.RSP_DRV_STATUS:
|
||||||
|
st = proto.decode_drv_status(payload)
|
||||||
|
if st and 0 <= st.ch < proto.NUM_CHANNELS:
|
||||||
|
self.drv_status_updated.emit(st.ch, st)
|
||||||
|
|
||||||
|
elif cmd == proto.RSP_POSITION:
|
||||||
|
pos = proto.decode_position(payload)
|
||||||
|
if pos and 0 <= pos.ch < proto.NUM_CHANNELS:
|
||||||
|
self.position_updated.emit(pos.ch, pos.position)
|
||||||
|
|
||||||
|
elif cmd == proto.EVT_MOTION_DONE:
|
||||||
|
ev = proto.decode_event_position(payload)
|
||||||
|
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
|
||||||
|
self.motion_done.emit(ev.ch, ev.position)
|
||||||
|
|
||||||
|
elif cmd == proto.EVT_STOPPED:
|
||||||
|
ev = proto.decode_event_position(payload)
|
||||||
|
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
|
||||||
|
self.stopped.emit(ev.ch, ev.position)
|
||||||
|
|
||||||
|
elif cmd == proto.EVT_FAULT:
|
||||||
|
ev = proto.decode_fault(payload)
|
||||||
|
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
|
||||||
|
self.fault_occurred.emit(ev.ch, ev.position)
|
||||||
Executable
+374
@@ -0,0 +1,374 @@
|
|||||||
|
"""T3R binary serial protocol — host-side codec.
|
||||||
|
|
||||||
|
A faithful Python port of the wire protocol implemented in ``main/protocol.c``.
|
||||||
|
Pure standard library (no PyQt / pyserial), so it can be unit-tested on its own.
|
||||||
|
|
||||||
|
Frame layout (all multi-byte fields little-endian):
|
||||||
|
|
||||||
|
+------+------+------+----------------+------+
|
||||||
|
| SOF | CMD | LEN | PAYLOAD[LEN] | CRC8 |
|
||||||
|
+------+------+------+----------------+------+
|
||||||
|
0xA5 1 B 1 B LEN bytes 1 B
|
||||||
|
|
||||||
|
CRC8 is CRC-8/SMBUS (poly 0x07, init 0x00) over CMD, LEN and PAYLOAD.
|
||||||
|
See PROTOCOL.md for the full catalogue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
SOF = 0xA5
|
||||||
|
PROTO_VERSION = 1
|
||||||
|
|
||||||
|
# ---- Requests (host -> device) --------------------------------------------
|
||||||
|
CMD_PING = 0x01
|
||||||
|
CMD_SET_MICROSTEP = 0x10
|
||||||
|
CMD_SET_CURRENT = 0x11
|
||||||
|
CMD_ENABLE = 0x12
|
||||||
|
CMD_DISABLE = 0x13
|
||||||
|
CMD_ENABLE_MASK = 0x14
|
||||||
|
CMD_MOVE = 0x20
|
||||||
|
CMD_JOG = 0x21
|
||||||
|
CMD_STOP = 0x22
|
||||||
|
CMD_STOP_ALL = 0x23
|
||||||
|
CMD_MOVE_GROUP = 0x24
|
||||||
|
CMD_JOG_GROUP = 0x25
|
||||||
|
CMD_GET_INFO = 0x30
|
||||||
|
CMD_GET_DRV_STATUS = 0x31
|
||||||
|
CMD_GET_POSITION = 0x32
|
||||||
|
CMD_SET_POSITION = 0x33
|
||||||
|
CMD_READ_REG = 0x40
|
||||||
|
CMD_WRITE_REG = 0x41
|
||||||
|
|
||||||
|
# ---- Responses (device -> host) -------------------------------------------
|
||||||
|
RSP_ACK = 0x81
|
||||||
|
RSP_PONG = 0x82
|
||||||
|
RSP_INFO = 0x83
|
||||||
|
RSP_DRV_STATUS = 0x84
|
||||||
|
RSP_POSITION = 0x85
|
||||||
|
RSP_REG = 0x86
|
||||||
|
|
||||||
|
# ---- Asynchronous events (device -> host) ---------------------------------
|
||||||
|
EVT_MOTION_DONE = 0xE0
|
||||||
|
EVT_STOPPED = 0xE1
|
||||||
|
EVT_FAULT = 0xE2
|
||||||
|
|
||||||
|
# Human-readable names, used by the log/console.
|
||||||
|
CMD_NAMES = {
|
||||||
|
CMD_PING: "PING", CMD_SET_MICROSTEP: "SET_MICROSTEP",
|
||||||
|
CMD_SET_CURRENT: "SET_CURRENT", CMD_ENABLE: "ENABLE", CMD_DISABLE: "DISABLE",
|
||||||
|
CMD_ENABLE_MASK: "ENABLE_MASK",
|
||||||
|
CMD_MOVE: "MOVE", CMD_JOG: "JOG", CMD_STOP: "STOP", CMD_STOP_ALL: "STOP_ALL",
|
||||||
|
CMD_MOVE_GROUP: "MOVE_GROUP", CMD_JOG_GROUP: "JOG_GROUP",
|
||||||
|
CMD_GET_INFO: "GET_INFO", CMD_GET_DRV_STATUS: "GET_DRV_STATUS",
|
||||||
|
CMD_GET_POSITION: "GET_POSITION", CMD_SET_POSITION: "SET_POSITION",
|
||||||
|
CMD_READ_REG: "READ_REG", CMD_WRITE_REG: "WRITE_REG",
|
||||||
|
RSP_ACK: "ACK", RSP_PONG: "PONG", RSP_INFO: "INFO",
|
||||||
|
RSP_DRV_STATUS: "DRV_STATUS", RSP_POSITION: "POSITION", RSP_REG: "REG",
|
||||||
|
EVT_MOTION_DONE: "MOTION_DONE", EVT_STOPPED: "STOPPED", EVT_FAULT: "FAULT",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- ACK status codes ------------------------------------------------------
|
||||||
|
STATUS_NAMES = {
|
||||||
|
0x00: "OK", 0x02: "bad length", 0x03: "bad channel", 0x04: "bad parameter",
|
||||||
|
0x05: "busy", 0x06: "SPI comms fault", 0x07: "unknown command",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- INFO.state ------------------------------------------------------------
|
||||||
|
STATE_NAMES = {0: "IDLE", 1: "MOVING", 2: "JOGGING", 3: "STOPPING"}
|
||||||
|
|
||||||
|
# ---- Fault mask bits -------------------------------------------------------
|
||||||
|
FAULT_BITS = [
|
||||||
|
(1 << 0, "SHORT_GND_A"),
|
||||||
|
(1 << 1, "SHORT_GND_B"),
|
||||||
|
(1 << 2, "OVERTEMP"),
|
||||||
|
(1 << 3, "OVERTEMP_WARN"),
|
||||||
|
(1 << 4, "OPEN_LOAD_A"),
|
||||||
|
(1 << 5, "OPEN_LOAD_B"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MICROSTEPS = [1, 2, 4, 8, 16, 32, 64, 128, 256]
|
||||||
|
MAX_VELOCITY = 200_000 # steps/s (MOTOR_MAX_VELOCITY)
|
||||||
|
MAX_ACCEL = 2_000_000 # steps/s2 (MOTOR_MAX_ACCEL)
|
||||||
|
NUM_CHANNELS = 4
|
||||||
|
|
||||||
|
|
||||||
|
def fault_names(mask: int) -> str:
|
||||||
|
"""Render a fault mask as a comma-separated list, or 'none'."""
|
||||||
|
names = [name for bit, name in FAULT_BITS if mask & bit]
|
||||||
|
return ", ".join(names) if names else "none"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRC-8 (poly 0x07, init 0x00) — matches the firmware reference.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def crc8(data: bytes) -> int:
|
||||||
|
crc = 0
|
||||||
|
for byte in data:
|
||||||
|
crc ^= byte
|
||||||
|
for _ in range(8):
|
||||||
|
crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
|
||||||
|
return crc
|
||||||
|
|
||||||
|
|
||||||
|
def build_frame(cmd: int, payload: bytes = b"") -> bytes:
|
||||||
|
body = bytes([cmd, len(payload)]) + payload
|
||||||
|
return bytes([SOF]) + body + bytes([crc8(body)])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request encoders — each returns a complete frame ready to write.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def ping() -> bytes:
|
||||||
|
return build_frame(CMD_PING)
|
||||||
|
|
||||||
|
|
||||||
|
def set_microstep(ch: int, microsteps: int) -> bytes:
|
||||||
|
return build_frame(CMD_SET_MICROSTEP, struct.pack("<BH", ch, microsteps))
|
||||||
|
|
||||||
|
|
||||||
|
def set_current(ch: int, run_ma: int, hold_ma: int, ihold_delay: int) -> bytes:
|
||||||
|
return build_frame(CMD_SET_CURRENT,
|
||||||
|
struct.pack("<BHHB", ch, run_ma, hold_ma, ihold_delay))
|
||||||
|
|
||||||
|
|
||||||
|
def enable(ch: int) -> bytes:
|
||||||
|
return build_frame(CMD_ENABLE, bytes([ch]))
|
||||||
|
|
||||||
|
|
||||||
|
def disable(ch: int) -> bytes:
|
||||||
|
return build_frame(CMD_DISABLE, bytes([ch]))
|
||||||
|
|
||||||
|
|
||||||
|
def enable_mask(mask: int) -> bytes:
|
||||||
|
"""Energise exactly the channels in `mask` (bit i = channel i); 0 = all off."""
|
||||||
|
return build_frame(CMD_ENABLE_MASK, bytes([mask & 0x0F]))
|
||||||
|
|
||||||
|
|
||||||
|
def move(ch: int, steps: int, velocity: int, accel: int) -> bytes:
|
||||||
|
return build_frame(CMD_MOVE, struct.pack("<BiII", ch, steps, velocity, accel))
|
||||||
|
|
||||||
|
|
||||||
|
def jog(ch: int, velocity: int, accel: int) -> bytes:
|
||||||
|
return build_frame(CMD_JOG, struct.pack("<BiI", ch, velocity, accel))
|
||||||
|
|
||||||
|
|
||||||
|
def move_group(mask: int, steps: int, velocity: int, accel: int) -> bytes:
|
||||||
|
"""Ganged move: every channel in `mask` steps together in lockstep."""
|
||||||
|
return build_frame(CMD_MOVE_GROUP,
|
||||||
|
struct.pack("<BiII", mask & 0x0F, steps, velocity, accel))
|
||||||
|
|
||||||
|
|
||||||
|
def jog_group(mask: int, velocity: int, accel: int) -> bytes:
|
||||||
|
"""Ganged jog: every channel in `mask` runs together (velocity 0 = stop)."""
|
||||||
|
return build_frame(CMD_JOG_GROUP,
|
||||||
|
struct.pack("<BiI", mask & 0x0F, velocity, accel))
|
||||||
|
|
||||||
|
|
||||||
|
def stop(ch: int, hard: bool) -> bytes:
|
||||||
|
return build_frame(CMD_STOP, struct.pack("<BB", ch, 1 if hard else 0))
|
||||||
|
|
||||||
|
|
||||||
|
def stop_all() -> bytes:
|
||||||
|
return build_frame(CMD_STOP_ALL)
|
||||||
|
|
||||||
|
|
||||||
|
def get_info(ch: int) -> bytes:
|
||||||
|
return build_frame(CMD_GET_INFO, bytes([ch]))
|
||||||
|
|
||||||
|
|
||||||
|
def get_drv_status(ch: int) -> bytes:
|
||||||
|
return build_frame(CMD_GET_DRV_STATUS, bytes([ch]))
|
||||||
|
|
||||||
|
|
||||||
|
def get_position(ch: int) -> bytes:
|
||||||
|
return build_frame(CMD_GET_POSITION, bytes([ch]))
|
||||||
|
|
||||||
|
|
||||||
|
def set_position(ch: int, position: int) -> bytes:
|
||||||
|
return build_frame(CMD_SET_POSITION, struct.pack("<Bi", ch, position))
|
||||||
|
|
||||||
|
|
||||||
|
def read_reg(ch: int, reg: int) -> bytes:
|
||||||
|
return build_frame(CMD_READ_REG, struct.pack("<BB", ch, reg))
|
||||||
|
|
||||||
|
|
||||||
|
def write_reg(ch: int, reg: int, value: int) -> bytes:
|
||||||
|
return build_frame(CMD_WRITE_REG, struct.pack("<BBI", ch, reg, value))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Response / event decoders. Each returns a dataclass (or None on bad length).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Pong:
|
||||||
|
proto_ver: int
|
||||||
|
fw_version: int
|
||||||
|
num_channels: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ack:
|
||||||
|
req_cmd: int
|
||||||
|
status: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.status == 0x00
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Info:
|
||||||
|
ch: int
|
||||||
|
state: int
|
||||||
|
position: int
|
||||||
|
velocity: int
|
||||||
|
microsteps: int
|
||||||
|
run_ma: int
|
||||||
|
hold_ma: int
|
||||||
|
enabled: bool
|
||||||
|
comms_ok: bool
|
||||||
|
fault_mask: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DrvStatus:
|
||||||
|
ch: int
|
||||||
|
raw: int
|
||||||
|
fault_mask: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def standstill(self) -> bool:
|
||||||
|
return bool(self.raw & (1 << 31))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cs_actual(self) -> int:
|
||||||
|
return (self.raw >> 16) & 0x1F
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sg_result(self) -> int:
|
||||||
|
return self.raw & 0x3FF
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
ch: int
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Reg:
|
||||||
|
ch: int
|
||||||
|
reg: int
|
||||||
|
value: int
|
||||||
|
|
||||||
|
|
||||||
|
def decode_pong(p: bytes):
|
||||||
|
if len(p) < 4:
|
||||||
|
return None
|
||||||
|
proto, fw, nch = struct.unpack_from("<BHB", p, 0)
|
||||||
|
return Pong(proto, fw, nch)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_ack(p: bytes):
|
||||||
|
if len(p) < 2:
|
||||||
|
return None
|
||||||
|
return Ack(p[0], p[1])
|
||||||
|
|
||||||
|
|
||||||
|
def decode_info(p: bytes):
|
||||||
|
if len(p) < 18:
|
||||||
|
return None
|
||||||
|
(ch, state, pos, vel, micro, run_ma, hold_ma, flags, fault) = \
|
||||||
|
struct.unpack_from("<BBiIHHHBB", p, 0)
|
||||||
|
return Info(ch, state, pos, vel, micro, run_ma, hold_ma,
|
||||||
|
bool(flags & 0x01), bool(flags & 0x02), fault)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_drv_status(p: bytes):
|
||||||
|
if len(p) < 6:
|
||||||
|
return None
|
||||||
|
ch, raw, fault = struct.unpack_from("<BIB", p, 0)
|
||||||
|
return DrvStatus(ch, raw, fault)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_position(p: bytes):
|
||||||
|
if len(p) < 5:
|
||||||
|
return None
|
||||||
|
ch, pos = struct.unpack_from("<Bi", p, 0)
|
||||||
|
return Position(ch, pos)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_reg(p: bytes):
|
||||||
|
if len(p) < 6:
|
||||||
|
return None
|
||||||
|
ch, reg, value = struct.unpack_from("<BBI", p, 0)
|
||||||
|
return Reg(ch, reg, value)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_event_position(p: bytes):
|
||||||
|
"""MOTION_DONE / STOPPED share the (ch, position) layout."""
|
||||||
|
return decode_position(p)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_fault(p: bytes):
|
||||||
|
if len(p) < 2:
|
||||||
|
return None
|
||||||
|
return Position(p[0], p[1]) # reuse (ch, value); value is the fault mask
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Incremental frame parser — mirrors the firmware byte-wise state machine.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FrameParser:
|
||||||
|
"""Feed raw bytes, get back a list of (cmd, payload) for each valid frame.
|
||||||
|
|
||||||
|
Bad-CRC and oversized frames are dropped silently; the parser resyncs on
|
||||||
|
the next SOF, exactly like the device-side parser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAX_PAYLOAD = 64
|
||||||
|
|
||||||
|
_SOF, _CMD, _LEN, _PAYLOAD, _CRC = range(5)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._state = self._SOF
|
||||||
|
self._cmd = 0
|
||||||
|
self._len = 0
|
||||||
|
self._payload = bytearray()
|
||||||
|
|
||||||
|
def feed(self, data: bytes):
|
||||||
|
out = []
|
||||||
|
for b in data:
|
||||||
|
if self._state == self._SOF:
|
||||||
|
if b == SOF:
|
||||||
|
self._state = self._CMD
|
||||||
|
elif self._state == self._CMD:
|
||||||
|
self._cmd = b
|
||||||
|
self._state = self._LEN
|
||||||
|
elif self._state == self._LEN:
|
||||||
|
self._len = b
|
||||||
|
self._payload.clear()
|
||||||
|
if b > self.MAX_PAYLOAD:
|
||||||
|
self._state = self._SOF # too big: resync
|
||||||
|
elif b == 0:
|
||||||
|
self._state = self._CRC
|
||||||
|
else:
|
||||||
|
self._state = self._PAYLOAD
|
||||||
|
elif self._state == self._PAYLOAD:
|
||||||
|
self._payload.append(b)
|
||||||
|
if len(self._payload) >= self._len:
|
||||||
|
self._state = self._CRC
|
||||||
|
elif self._state == self._CRC:
|
||||||
|
body = bytes([self._cmd, self._len]) + bytes(self._payload)
|
||||||
|
if crc8(body) == b:
|
||||||
|
out.append((self._cmd, bytes(self._payload)))
|
||||||
|
# else bad CRC: drop, resync
|
||||||
|
self._state = self._SOF
|
||||||
|
return out
|
||||||
Regular → Executable
Regular → Executable
+153
-20
@@ -4,6 +4,9 @@ Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library.
|
|||||||
Provides camera control, live streaming, and image capture capabilities.
|
Provides camera control, live streaming, and image capture capabilities.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pyueye import ueye
|
from pyueye import ueye
|
||||||
@@ -12,10 +15,71 @@ from PyQt6.QtGui import QImage
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IDS_USB_VENDOR_ID = "1409"
|
||||||
|
|
||||||
|
|
||||||
|
def find_camera_bus_conflicts() -> List[Tuple[str, str, str]]:
|
||||||
|
"""Return (tty_name, bus, product) for serial adapters sharing a USB host
|
||||||
|
controller with the IDS camera.
|
||||||
|
|
||||||
|
A uC480 USB2 camera at high pixel clock needs nearly the whole 480 Mbit/s
|
||||||
|
of its host controller. When a full-speed serial adapter (ESP32 CDC,
|
||||||
|
FTDI, …) on the same controller has its port *open*, the kernel's
|
||||||
|
periodic split transactions starve the camera's bulk stream — measured
|
||||||
|
on this rig: 22 fps with all ports closed, <1.5 fps with either the T3R
|
||||||
|
(ttyACM0) or BBD202 (ttyUSB1) port open, full recovery on close. The
|
||||||
|
only real fix is plugging the camera into a port on a different
|
||||||
|
controller (e.g. a USB-3/xHCI port); this check exists so the UI can
|
||||||
|
say that instead of silently dropping frames.
|
||||||
|
"""
|
||||||
|
cam_buses = set()
|
||||||
|
for vid_path in glob.glob("/sys/bus/usb/devices/*/idVendor"):
|
||||||
|
try:
|
||||||
|
with open(vid_path) as f:
|
||||||
|
if f.read().strip() != IDS_USB_VENDOR_ID:
|
||||||
|
continue
|
||||||
|
with open(os.path.join(os.path.dirname(vid_path), "busnum")) as f:
|
||||||
|
cam_buses.add(f.read().strip())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
conflicts: List[Tuple[str, str, str]] = []
|
||||||
|
if not cam_buses:
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
tty_paths = glob.glob("/sys/class/tty/ttyUSB*") + glob.glob("/sys/class/tty/ttyACM*")
|
||||||
|
for tty_path in sorted(tty_paths):
|
||||||
|
real = os.path.realpath(os.path.join(tty_path, "device"))
|
||||||
|
m = re.search(r"/usb(\d+)/", real)
|
||||||
|
if not m or m.group(1) not in cam_buses:
|
||||||
|
continue
|
||||||
|
# Walk up from the interface dir to the USB device dir for its name
|
||||||
|
product = ""
|
||||||
|
d = real
|
||||||
|
for _ in range(6):
|
||||||
|
d = os.path.dirname(d)
|
||||||
|
if os.path.exists(os.path.join(d, "busnum")):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, "product")) as f:
|
||||||
|
product = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
conflicts.append((os.path.basename(tty_path), m.group(1), product))
|
||||||
|
|
||||||
|
if conflicts:
|
||||||
|
devs = ", ".join(f"/dev/{n} ({p})" if p else f"/dev/{n}" for n, _, p in conflicts)
|
||||||
|
logger.warning(
|
||||||
|
f"Camera shares USB bus {sorted(cam_buses)} with serial adapters: {devs}. "
|
||||||
|
f"Opening any of these ports will collapse the camera frame rate — "
|
||||||
|
f"move the camera to a port on another USB controller."
|
||||||
|
)
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
class UC480Camera(QObject):
|
class UC480Camera(QObject):
|
||||||
"""
|
"""
|
||||||
@@ -60,7 +124,9 @@ class UC480Camera(QObject):
|
|||||||
self.height = 0
|
self.height = 0
|
||||||
self.bits_per_pixel = 24 # Default to 24-bit color
|
self.bits_per_pixel = 24 # Default to 24-bit color
|
||||||
self.bytes_per_pixel = 3
|
self.bytes_per_pixel = 3
|
||||||
self.color_mode = ueye.IS_CM_BGR8_PACKED
|
# RGB (not BGR) so get_frame() can hand the buffer straight to QImage
|
||||||
|
# without a per-frame channel-reversal copy.
|
||||||
|
self.color_mode = ueye.IS_CM_RGB8_PACKED
|
||||||
|
|
||||||
# Lock to serialize parameter changes that require stopping live video
|
# Lock to serialize parameter changes that require stopping live video
|
||||||
self._settings_lock = threading.Lock()
|
self._settings_lock = threading.Lock()
|
||||||
@@ -159,10 +225,19 @@ class UC480Camera(QObject):
|
|||||||
self.is_initialized = True
|
self.is_initialized = True
|
||||||
logger.info(f"Camera initialized: {self.width}x{self.height}, {self.bits_per_pixel}bpp")
|
logger.info(f"Camera initialized: {self.width}x{self.height}, {self.bits_per_pixel}bpp")
|
||||||
|
|
||||||
# Set default settings
|
# Set default settings. Pixel clock caps the max sensor readout
|
||||||
|
# rate, which in turn caps achievable fps regardless of the
|
||||||
|
# requested framerate below — use the sensor's max rather than
|
||||||
|
# a hardcoded guess, since a too-low clock silently forces
|
||||||
|
# is_SetFrameRate to negotiate down to a much lower actual fps.
|
||||||
self.set_exposure(10.0) # 10ms default exposure
|
self.set_exposure(10.0) # 10ms default exposure
|
||||||
self.set_pixel_clock(30) # 30MHz default pixel clock
|
clock_range = self.get_pixel_clock_range()
|
||||||
self.set_framerate(30.0) # 30fps default
|
if clock_range is not None:
|
||||||
|
min_clock, max_clock, _ = clock_range
|
||||||
|
self.set_pixel_clock(max_clock)
|
||||||
|
else:
|
||||||
|
self.set_pixel_clock(30) # fallback if range query fails
|
||||||
|
self.set_framerate(30.0) # 30fps default (actual may be lower)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -210,10 +285,32 @@ class UC480Camera(QObject):
|
|||||||
self.error_occurred.emit(f"Failed to start capture: {ret}")
|
self.error_occurred.emit(f"Failed to start capture: {ret}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
ret = ueye.is_EnableEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME)
|
||||||
|
if ret != ueye.IS_SUCCESS:
|
||||||
|
logger.error(f"Failed to enable frame event: {ret}")
|
||||||
|
|
||||||
self.is_capturing = True
|
self.is_capturing = True
|
||||||
logger.info("Video capture started")
|
logger.info("Video capture started")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def wait_for_frame(self, timeout_ms: int = 200) -> bool:
|
||||||
|
"""
|
||||||
|
Block until the camera signals that a new frame has landed in
|
||||||
|
image memory (or until timeout_ms elapses).
|
||||||
|
|
||||||
|
Without this, a caller polling get_frame() in a tight loop just
|
||||||
|
re-reads the same still-unfinished/unchanged buffer as fast as the
|
||||||
|
GIL allows — burning CPU without raising the delivered frame rate,
|
||||||
|
and occasionally reading a frame mid-write (tearing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a new frame arrived, False on timeout/error.
|
||||||
|
"""
|
||||||
|
if not self.is_capturing:
|
||||||
|
return False
|
||||||
|
ret = ueye.is_WaitEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME, timeout_ms)
|
||||||
|
return ret == ueye.IS_SUCCESS
|
||||||
|
|
||||||
def stop_capture(self) -> bool:
|
def stop_capture(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Stop continuous video capture.
|
Stop continuous video capture.
|
||||||
@@ -224,6 +321,7 @@ class UC480Camera(QObject):
|
|||||||
if not self.is_capturing:
|
if not self.is_capturing:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
ueye.is_DisableEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME)
|
||||||
ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
|
ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
|
||||||
self.is_capturing = False # Always reset, even if the call fails
|
self.is_capturing = False # Always reset, even if the call fails
|
||||||
if ret != ueye.IS_SUCCESS:
|
if ret != ueye.IS_SUCCESS:
|
||||||
@@ -278,25 +376,23 @@ class UC480Camera(QObject):
|
|||||||
copy=True
|
copy=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reshape to image dimensions
|
# Reshape to image dimensions (view, no copy — array already owns
|
||||||
|
# its memory since get_data() was called with copy=True above)
|
||||||
frame = np.reshape(array, (self.height, self.width, self.bytes_per_pixel))
|
frame = np.reshape(array, (self.height, self.width, self.bytes_per_pixel))
|
||||||
|
|
||||||
# Convert to QImage (BGR to RGB)
|
|
||||||
height, width, channel = frame.shape
|
height, width, channel = frame.shape
|
||||||
bytes_per_line = self.bytes_per_pixel * width
|
bytes_per_line = self.bytes_per_pixel * width
|
||||||
|
|
||||||
# Convert BGR to RGB
|
|
||||||
rgb_frame = frame[:, :, ::-1].copy()
|
|
||||||
|
|
||||||
q_image = QImage(
|
q_image = QImage(
|
||||||
rgb_frame.data,
|
frame.data,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
bytes_per_line,
|
bytes_per_line,
|
||||||
QImage.Format.Format_RGB888
|
QImage.Format.Format_RGB888
|
||||||
)
|
)
|
||||||
|
# Must copy: this QImage crosses threads via a queued signal,
|
||||||
# Make a copy since the numpy array will be deleted
|
# which hands the slot a new Python wrapper around the same
|
||||||
|
# frame that gets delivered after `frame` may already be GC'd —
|
||||||
|
# confirmed by testing that skipping this copy corrupts pixels.
|
||||||
return q_image.copy()
|
return q_image.copy()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -355,6 +451,30 @@ class UC480Camera(QObject):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_pixel_clock_range(self) -> Optional[Tuple[int, int, int]]:
|
||||||
|
"""
|
||||||
|
Query the sensor's supported pixel clock range.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(min_mhz, max_mhz, increment_mhz), or None if the query failed
|
||||||
|
"""
|
||||||
|
if not self.is_initialized:
|
||||||
|
return None
|
||||||
|
|
||||||
|
clock_range = (ueye.c_uint * 3)()
|
||||||
|
ret = ueye.is_PixelClock(
|
||||||
|
self.h_cam,
|
||||||
|
ueye.IS_PIXELCLOCK_CMD_GET_RANGE,
|
||||||
|
clock_range,
|
||||||
|
ueye.sizeof(clock_range)
|
||||||
|
)
|
||||||
|
|
||||||
|
if ret == ueye.IS_SUCCESS:
|
||||||
|
return clock_range[0].value, clock_range[1].value, clock_range[2].value
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to get pixel clock range: {ret}")
|
||||||
|
return None
|
||||||
|
|
||||||
def set_pixel_clock(self, pixel_clock_mhz: int) -> bool:
|
def set_pixel_clock(self, pixel_clock_mhz: int) -> bool:
|
||||||
"""
|
"""
|
||||||
Set camera pixel clock.
|
Set camera pixel clock.
|
||||||
@@ -376,7 +496,7 @@ class UC480Camera(QObject):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ret == ueye.IS_SUCCESS:
|
if ret == ueye.IS_SUCCESS:
|
||||||
logger.debug(f"Pixel clock set to {pixel_clock_mhz}MHz")
|
logger.info(f"Pixel clock set to {pixel_clock_mhz}MHz")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to set pixel clock: {ret}")
|
logger.error(f"Failed to set pixel clock: {ret}")
|
||||||
@@ -384,7 +504,10 @@ class UC480Camera(QObject):
|
|||||||
|
|
||||||
def set_framerate(self, fps: float) -> bool:
|
def set_framerate(self, fps: float) -> bool:
|
||||||
"""
|
"""
|
||||||
Set camera framerate.
|
Set camera framerate. The SDK negotiates the requested value against
|
||||||
|
the current pixel clock/exposure/AOI and may return a lower actual
|
||||||
|
rate — that negotiated value is what's logged and returned, not the
|
||||||
|
request, since silently trusting the request hides the real cap.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
fps: Frames per second
|
fps: Frames per second
|
||||||
@@ -400,7 +523,13 @@ class UC480Camera(QObject):
|
|||||||
ret = ueye.is_SetFrameRate(self.h_cam, new_fps, actual_fps)
|
ret = ueye.is_SetFrameRate(self.h_cam, new_fps, actual_fps)
|
||||||
|
|
||||||
if ret == ueye.IS_SUCCESS:
|
if ret == ueye.IS_SUCCESS:
|
||||||
logger.debug(f"Framerate set to {fps}fps")
|
if actual_fps.value < fps * 0.9:
|
||||||
|
logger.warning(
|
||||||
|
f"Requested {fps}fps but camera negotiated only "
|
||||||
|
f"{actual_fps.value:.1f}fps (pixel clock/exposure/AOI-limited)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"Framerate set to {actual_fps.value:.1f}fps")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to set framerate: {ret}")
|
logger.error(f"Failed to set framerate: {ret}")
|
||||||
@@ -514,12 +643,16 @@ class CameraStreamThread(QThread):
|
|||||||
return
|
return
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
|
# Block until the camera actually has a new frame ready, instead
|
||||||
|
# of re-reading (and re-copying/re-emitting) the same buffer as
|
||||||
|
# fast as possible. The short timeout just bounds how quickly a
|
||||||
|
# stop() request is noticed.
|
||||||
|
if not self.camera.wait_for_frame(200):
|
||||||
|
continue
|
||||||
|
|
||||||
frame = self.camera.get_frame()
|
frame = self.camera.get_frame()
|
||||||
if frame is not None:
|
if frame is not None:
|
||||||
self.frame_ready.emit(frame)
|
self.frame_ready.emit(frame)
|
||||||
else:
|
|
||||||
# Small delay on error to prevent CPU spinning
|
|
||||||
self.msleep(10)
|
|
||||||
|
|
||||||
self.camera.stop_capture()
|
self.camera.stop_capture()
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+98
-8
@@ -662,6 +662,12 @@
|
|||||||
<layout class="QGridLayout" name="gridLayout_4">
|
<layout class="QGridLayout" name="gridLayout_4">
|
||||||
<item row="0" column="4">
|
<item row="0" column="4">
|
||||||
<widget class="QPushButton" name="bbd_enable_all_btn">
|
<widget class="QPushButton" name="bbd_enable_all_btn">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>36</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Toggle Axes Enable</string>
|
<string>Toggle Axes Enable</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -699,9 +705,15 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="4" column="4" alignment="Qt::AlignmentFlag::AlignLeft">
|
<item row="4" column="4" alignment="Qt::AlignmentFlag::AlignLeft">
|
||||||
<widget class="QLabel" name="bbd_current_y_position_indicator">
|
<widget class="QLabel" name="bbd_current_y_position_indicator">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>170</width>
|
||||||
|
<height>44</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="maximumSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>100</width>
|
<width>220</width>
|
||||||
<height>16777215</height>
|
<height>16777215</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
@@ -734,6 +746,12 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="0" column="0">
|
<item row="0" column="0">
|
||||||
<widget class="QPushButton" name="bbd_home_all_btn">
|
<widget class="QPushButton" name="bbd_home_all_btn">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>36</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Home All Axes</string>
|
<string>Home All Axes</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -741,12 +759,23 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="1" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
<item row="1" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||||
<widget class="QPushButton" name="bbd_jog_y_pos_btn">
|
<widget class="QPushButton" name="bbd_jog_y_pos_btn">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>44</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="maximumSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>150</width>
|
<width>190</width>
|
||||||
<height>16777215</height>
|
<height>16777215</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<pointsize>14</pointsize>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Y+</string>
|
<string>Y+</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -756,10 +785,15 @@
|
|||||||
<widget class="QLabel" name="label_21">
|
<widget class="QLabel" name="label_21">
|
||||||
<property name="maximumSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>100</width>
|
<width>130</width>
|
||||||
<height>16777215</height>
|
<height>16777215</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<pointsize>12</pointsize>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>X Position:</string>
|
<string>X Position:</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -770,9 +804,15 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="4" column="1" alignment="Qt::AlignmentFlag::AlignLeft">
|
<item row="4" column="1" alignment="Qt::AlignmentFlag::AlignLeft">
|
||||||
<widget class="QLabel" name="bbd_current_x_position_indicator">
|
<widget class="QLabel" name="bbd_current_x_position_indicator">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>170</width>
|
||||||
|
<height>44</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="maximumSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>100</width>
|
<width>220</width>
|
||||||
<height>16777215</height>
|
<height>16777215</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
@@ -792,12 +832,23 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="2" column="1">
|
<item row="2" column="1">
|
||||||
<widget class="QPushButton" name="bbd_jog_x_neg_btn">
|
<widget class="QPushButton" name="bbd_jog_x_neg_btn">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>44</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="maximumSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>150</width>
|
<width>190</width>
|
||||||
<height>16777215</height>
|
<height>16777215</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<pointsize>14</pointsize>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>X-</string>
|
<string>X-</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -818,12 +869,23 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="3" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
<item row="3" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||||
<widget class="QPushButton" name="bbd_jog_y_neg_btn">
|
<widget class="QPushButton" name="bbd_jog_y_neg_btn">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>44</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="maximumSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>150</width>
|
<width>190</width>
|
||||||
<height>16777215</height>
|
<height>16777215</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<pointsize>14</pointsize>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Y-</string>
|
<string>Y-</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -831,12 +893,23 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="2" column="3">
|
<item row="2" column="3">
|
||||||
<widget class="QPushButton" name="bbd_jog_x_pos_btn">
|
<widget class="QPushButton" name="bbd_jog_x_pos_btn">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>44</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="maximumSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>150</width>
|
<width>190</width>
|
||||||
<height>16777215</height>
|
<height>16777215</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<pointsize>14</pointsize>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>X+</string>
|
<string>X+</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -859,10 +932,15 @@
|
|||||||
<widget class="QLabel" name="label_23">
|
<widget class="QLabel" name="label_23">
|
||||||
<property name="maximumSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>100</width>
|
<width>130</width>
|
||||||
<height>16777215</height>
|
<height>16777215</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<pointsize>12</pointsize>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Y Position:</string>
|
<string>Y Position:</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -873,6 +951,12 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="5" column="0">
|
<item row="5" column="0">
|
||||||
<widget class="QPushButton" name="bbd_set_current_start_btn">
|
<widget class="QPushButton" name="bbd_set_current_start_btn">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>36</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Set Current Coords as Start Coords</string>
|
<string>Set Current Coords as Start Coords</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -880,6 +964,12 @@
|
|||||||
</item>
|
</item>
|
||||||
<item row="5" column="4">
|
<item row="5" column="4">
|
||||||
<widget class="QPushButton" name="bbd_set_delta_current_btn">
|
<widget class="QPushButton" name="bbd_set_delta_current_btn">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>36</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Calculate Delta (Current - Start)</string>
|
<string>Calculate Delta (Current - Start)</string>
|
||||||
</property>
|
</property>
|
||||||
|
|||||||
Regular → Executable
+16
@@ -118,6 +118,22 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pause_btn">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<family>Noto Sans Condensed ExtraBold</family>
|
||||||
|
<pointsize>24</pointsize>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>PAUSE</string>
|
||||||
|
</property>
|
||||||
|
<property name="checkable">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QPushButton" name="abort_btn">
|
<widget class="QPushButton" name="abort_btn">
|
||||||
<property name="font">
|
<property name="font">
|
||||||
|
|||||||
Regular → Executable
+669
-365
File diff suppressed because it is too large
Load Diff
Regular → Executable
+84
-86
@@ -1,19 +1,27 @@
|
|||||||
# SRAS Scan Binary Format — Version 4
|
# SRAS Scan Binary Format — Version 6
|
||||||
|
|
||||||
Each `.sras` file contains **one complete scan**: all GR rotation angles and all
|
Each `.sras` file contains **one complete scan**: all GR rotation angles and all
|
||||||
Y rows. Files are named `{prefix}.sras`.
|
Y rows. Files are named `{prefix}.sras`.
|
||||||
|
|
||||||
|
Starting in v6, each angle only scans the **bounding box of the nominal ROI
|
||||||
|
rotated by that specific angle** — not the worst case across all angles — so
|
||||||
|
`x_start`, `x_delta` (and therefore `n_frames`, the points/row count) and
|
||||||
|
`n_rows` all vary per angle. A 0°/180° scan of a wide, short ROI needs far
|
||||||
|
fewer rows than a 45° scan of the same ROI, and the file format reflects that
|
||||||
|
instead of forcing every angle to the largest bounding box.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## File Layout
|
## File Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
[Global Header — 43 bytes]
|
[Global Header — 49 bytes]
|
||||||
[Angle Table — n_angles × 4 bytes (float32 per angle)]
|
[Angle Table — n_angles × 4 bytes (float32 per angle, degrees)]
|
||||||
[Row Table — n_rows × 4 bytes (float32 per row)]
|
[Per-Angle Geometry Table— n_angles × 14 bytes (x_start f32, x_delta f32, n_frames u32, n_rows u16)]
|
||||||
|
[Row Table (ragged) — sum(n_rows) × 4 bytes (float32 per row, angle-major)]
|
||||||
[Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)]
|
[Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)]
|
||||||
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
|
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
|
||||||
[Waveform Data — n_angles × n_rows × n_channels × n_frames × samples_per_frame × bps bytes]
|
[Waveform Data (ragged) — per angle: n_rows[a] × n_channels × n_frames[a] × samples_per_frame × bps bytes]
|
||||||
```
|
```
|
||||||
|
|
||||||
All multi-byte integers and floats use **big-endian** byte order
|
All multi-byte integers and floats use **big-endian** byte order
|
||||||
@@ -21,33 +29,40 @@ All multi-byte integers and floats use **big-endian** byte order
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Global Header (42 bytes)
|
## Global Header (49 bytes)
|
||||||
|
|
||||||
| Offset | Size | Type | Field | Description |
|
| Offset | Size | Type | Field | Description |
|
||||||
|--------|------|-----------|--------------------|--------------------------------------------------|
|
|--------|------|-----------|--------------------|--------------------------------------------------|
|
||||||
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
|
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
|
||||||
| 4 | 1 | `uint8` | `version` | Format version — `4` |
|
| 4 | 1 | `uint8` | `version` | Format version — `6` |
|
||||||
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
|
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
|
||||||
| 7 | 2 | `uint16` | `n_rows` | Number of Y rows per angle |
|
| 7 | 4 | `float32` | `x_start_nominal` | Nominal (pre-rotation) X scan start, mm |
|
||||||
| 9 | 4 | `float32` | `x_start_mm` | X scan start position in mm |
|
| 11 | 4 | `float32` | `y_start_nominal` | Nominal (pre-rotation) Y scan start, mm |
|
||||||
| 13 | 4 | `float32` | `x_delta_mm` | X scan width in mm |
|
| 15 | 4 | `float32` | `x_delta_nominal` | Nominal (pre-rotation) X scan width, mm |
|
||||||
| 17 | 4 | `float32` | `velocity_mm_s` | Stage scan velocity in mm/s |
|
| 19 | 4 | `float32` | `y_delta_nominal` | Nominal (pre-rotation) Y scan height, mm |
|
||||||
| 21 | 4 | `float32` | `laser_freq_hz` | Laser repetition rate in Hz |
|
| 23 | 4 | `float32` | `row_spacing_mm` | Y spacing between rows, mm |
|
||||||
| 25 | 4 | `uint32` | `n_frames` | A-scans per row (= FastFrame count per channel) |
|
| 27 | 4 | `float32` | `velocity_mm_s` | Stage scan velocity in mm/s |
|
||||||
| 29 | 4 | `uint32` | `samples_per_frame`| Time samples per waveform |
|
| 31 | 4 | `float32` | `laser_freq_hz` | Laser repetition rate in Hz |
|
||||||
| 33 | 8 | `float64` | `sample_rate_hz` | Oscilloscope sample rate in Hz (e.g. 6.25e9) |
|
| 35 | 4 | `uint32` | `samples_per_frame`| Time samples per waveform |
|
||||||
| 41 | 1 | `uint8` | `bytes_per_sample` | Bytes per ADC sample: `1` = int8, `2` = int16 |
|
| 39 | 8 | `float64` | `sample_rate_hz` | Oscilloscope sample rate in Hz (e.g. 6.25e9) |
|
||||||
| 42 | 1 | `uint8` | `n_channels` | Number of channels recorded (currently `3`) |
|
| 47 | 1 | `uint8` | `bytes_per_sample` | Bytes per ADC sample: `1` = int8, `2` = int16 |
|
||||||
|
| 48 | 1 | `uint8` | `n_channels` | Number of channels recorded (currently `3`) |
|
||||||
|
|
||||||
**Total header size:** 43 bytes — verified:
|
**Total header size:** 49 bytes — verified:
|
||||||
`struct.calcsize(">4sBHHffffIIdBB") == 43`.
|
`struct.calcsize(">4sBHfffffffIdBB") == 49`.
|
||||||
|
|
||||||
|
The `*_nominal` fields describe the ROI as originally entered on the New Scan
|
||||||
|
page (XS/YS/XD/YD), **before** per-angle bounding-box expansion. They are for
|
||||||
|
reference/reconstruction only — the actual per-angle scan geometry used for
|
||||||
|
acquisition is in the Per-Angle Geometry Table below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Angle Table
|
## Angle Table
|
||||||
|
|
||||||
Immediately after the header: **n_angles** big-endian float32 values, one per
|
Immediately after the header: **n_angles** big-endian float32 values, one per
|
||||||
GR angle (degrees, 0–180).
|
GR angle (degrees, signed; magnitude 0–180, sign gives physical rotation
|
||||||
|
direction — negative for the current CW-rotating GR stage).
|
||||||
|
|
||||||
```
|
```
|
||||||
angle[0], angle[1], …, angle[n_angles - 1]
|
angle[0], angle[1], …, angle[n_angles - 1]
|
||||||
@@ -55,15 +70,36 @@ angle[0], angle[1], …, angle[n_angles - 1]
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Row Table
|
## Per-Angle Geometry Table
|
||||||
|
|
||||||
Immediately after the angle table: **n_rows** big-endian float32 values, one
|
Immediately after the angle table: **n_angles** fixed-size records, one per
|
||||||
per Y row (mm).
|
angle (same order as the angle table), each 14 bytes:
|
||||||
|
|
||||||
|
| Size | Type | Field | Description |
|
||||||
|
|------|-----------|------------|-------------------------------------------------------|
|
||||||
|
| 4 | `float32` | `x_start` | X scan start for this angle's bounding box, mm |
|
||||||
|
| 4 | `float32` | `x_delta` | X scan width for this angle's bounding box, mm |
|
||||||
|
| 4 | `uint32` | `n_frames` | A-scans per row for this angle (FastFrame count) |
|
||||||
|
| 2 | `uint16` | `n_rows` | Number of Y rows scanned for this angle |
|
||||||
|
|
||||||
|
Format string per record: `">ffIH"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Row Table (ragged)
|
||||||
|
|
||||||
|
Immediately after the per-angle geometry table: for each angle in order,
|
||||||
|
that angle's `n_rows` big-endian float32 Y positions (mm), concatenated with
|
||||||
|
no padding between angles.
|
||||||
|
|
||||||
```
|
```
|
||||||
y_mm[0], y_mm[1], …, y_mm[n_rows - 1]
|
# angle 0's rows, then angle 1's rows, …
|
||||||
|
y_mm[0][0], …, y_mm[0][n_rows[0]-1], y_mm[1][0], …, y_mm[n_angles-1][n_rows[-1]-1]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Row-table boundaries for angle *a* are derived from the per-angle geometry
|
||||||
|
table: `sum(n_rows[0:a])` gives the starting index into the flattened array.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Preamble Blocks
|
## Preamble Blocks
|
||||||
@@ -97,17 +133,20 @@ int8[] bg_data — raw ADC samples (same encoding as waveform data)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Waveform Data
|
## Waveform Data (ragged)
|
||||||
|
|
||||||
Immediately after the background block. Data is stored in **angle-major, row-minor**
|
Immediately after the background block. Data is stored in **angle-major,
|
||||||
order. Within each row, channels are interleaved in ascending channel-index
|
row-minor** order, but unlike earlier versions each angle contributes a
|
||||||
|
different number of rows (`n_rows[a]`) and a different number of frames per
|
||||||
|
row (`n_frames[a]`), both taken from that angle's Per-Angle Geometry Table
|
||||||
|
entry. Within each row, channels are interleaved in ascending channel-index
|
||||||
order, with each channel's FastFrame data written in frame order.
|
order, with each channel's FastFrame data written in frame order.
|
||||||
|
|
||||||
```
|
```
|
||||||
for angle in 0 … n_angles-1:
|
for angle a in 0 … n_angles-1:
|
||||||
for row in 0 … n_rows-1:
|
for row in 0 … n_rows[a]-1:
|
||||||
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
|
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
|
||||||
for frame in 0 … n_frames-1:
|
for frame in 0 … n_frames[a]-1:
|
||||||
samples[0 … samples_per_frame-1] # bps bytes each
|
samples[0 … samples_per_frame-1] # bps bytes each
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -117,81 +156,37 @@ int16**.
|
|||||||
|
|
||||||
Total data size:
|
Total data size:
|
||||||
```
|
```
|
||||||
n_angles × n_rows × 3 × n_frames × samples_per_frame × bytes_per_sample
|
sum over angles a of: n_rows[a] × 3 × n_frames[a] × samples_per_frame × bytes_per_sample
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Incomplete files:** If a scan is aborted the file is closed immediately and
|
> **Incomplete files:** If a scan is aborted the file is closed immediately and
|
||||||
> the data block will be shorter than the expected size. Readers should check
|
> the data block will be shorter than the expected size. Readers should
|
||||||
> `file_size >= header + angle_table + row_table + data` before reshaping.
|
> reconstruct the expected per-angle byte offsets from the Per-Angle Geometry
|
||||||
|
> Table and check `file_size` against the running total before reshaping —
|
||||||
|
> a fixed `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not
|
||||||
|
> work since row/frame counts are no longer uniform across angles.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Spatial Mapping
|
## Spatial Mapping
|
||||||
|
|
||||||
The *k*-th waveform (frame) in a row corresponds to the *k*-th laser pulse that
|
The *k*-th waveform (frame) in a row corresponds to the *k*-th laser pulse that
|
||||||
hit the sample. The physical X position of that pulse is:
|
hit the sample. For a row belonging to angle *a*, the physical X position of
|
||||||
|
that pulse is:
|
||||||
|
|
||||||
```
|
```
|
||||||
x_k = x_start_mm + k * (velocity_mm_s / laser_freq_hz)
|
x_k = x_start[a] + k * (velocity_mm_s / laser_freq_hz)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
using that angle's `x_start` from the Per-Angle Geometry Table (not
|
||||||
|
`x_start_nominal`).
|
||||||
## Python Read Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
import struct, numpy as np
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
HDR_FMT = ">4sBHHffffIIdBB"
|
|
||||||
HDR_SIZE = struct.calcsize(HDR_FMT) # 43 bytes
|
|
||||||
|
|
||||||
def read_sras(path):
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
hdr = struct.unpack(HDR_FMT, f.read(HDR_SIZE))
|
|
||||||
magic, ver, n_angles, n_rows, xs, xd, vel, freq, nf, spf, sr, bps, n_ch = hdr
|
|
||||||
assert magic == b"SRAS" and ver == 4, "Not a v4 SRAS file"
|
|
||||||
|
|
||||||
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4")
|
|
||||||
y_positions = np.frombuffer(f.read(n_rows * 4), dtype=">f4")
|
|
||||||
|
|
||||||
# Preamble blocks (one per channel)
|
|
||||||
preambles = []
|
|
||||||
for _ in range(n_ch):
|
|
||||||
(plen,) = struct.unpack(">H", f.read(2))
|
|
||||||
preambles.append(f.read(plen).decode("utf-8"))
|
|
||||||
|
|
||||||
# Background waveform block (v4+)
|
|
||||||
(n_bg,) = struct.unpack(">I", f.read(4))
|
|
||||||
background = np.frombuffer(f.read(n_bg), dtype=np.int8)
|
|
||||||
|
|
||||||
dtype = np.int8 if bps == 1 else ">i2"
|
|
||||||
data = np.frombuffer(f.read(), dtype=dtype).reshape(
|
|
||||||
n_angles, n_rows, n_ch, nf, spf
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"angles_deg": angles,
|
|
||||||
"y_positions_mm": y_positions,
|
|
||||||
"x_start_mm": xs,
|
|
||||||
"x_delta_mm": xd,
|
|
||||||
"velocity_mm_s": vel,
|
|
||||||
"laser_freq_hz": freq,
|
|
||||||
"sample_rate_hz": sr,
|
|
||||||
"n_channels": n_ch, # 3: CH1, CH3, CH4 (see Acquisition Settings)
|
|
||||||
"preambles": preambles, # WFMOutpre strings, same order as n_channels
|
|
||||||
"background": background,# shape: (n_bg_samples,) — CH1 noise reference
|
|
||||||
# shape: (n_angles, n_rows, n_channels, n_frames, samples_per_frame)
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Acquisition Settings (fixed by sc3_aui_app.py)
|
## Acquisition Settings (fixed by sc3_aui_app.py)
|
||||||
|
|
||||||
| Parameter | Value |
|
| Parameter | Value |
|
||||||
|----------------------|------------------------------|
|
|-----------------------|------------------------------|
|
||||||
| Oscilloscope trigger | CH2, rising edge, 1.24 V |
|
| Oscilloscope trigger | CH2, rising edge, 1.24 V |
|
||||||
| Trigger offset | 0 % (trigger at left edge) |
|
| Trigger offset | 0 % (trigger at left edge) |
|
||||||
| Sample rate | 6.25 GS/s (160 ps/sample) |
|
| Sample rate | 6.25 GS/s (160 ps/sample) |
|
||||||
@@ -211,3 +206,6 @@ def read_sras(path):
|
|||||||
| 2 | One file per scan; global header with `n_angles`/`n_rows`; separate angle and row tables; three channels (CH1, CH3, CH4) per row. |
|
| 2 | One file per scan; global header with `n_angles`/`n_rows`; separate angle and row tables; three channels (CH1, CH3, CH4) per row. |
|
||||||
| 3 | Added preamble blocks (WFMOutpre strings) after the row table, one length-prefixed UTF-8 block per channel. |
|
| 3 | Added preamble blocks (WFMOutpre strings) after the row table, one length-prefixed UTF-8 block per channel. |
|
||||||
| 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. |
|
| 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. |
|
||||||
|
| 5 | (skipped) |
|
||||||
|
| 6 | Each angle now scans only the bounding box of the nominal ROI rotated by that angle instead of the AABB-expanded worst case across all angles. Header no longer carries a single global `x_start`/`x_delta`/`n_rows` — replaced with `*_nominal` reference fields plus a new Per-Angle Geometry Table (`x_start`, `x_delta`, `n_frames`, `n_rows` per angle) and a ragged Row Table / Waveform Data block sized per angle. **Not compatible with v4 readers** (e.g. `sras_viewer.py`, which has not yet been updated for v6). |
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Executable
+471
@@ -0,0 +1,471 @@
|
|||||||
|
#!/opt/srasenv/bin/python3
|
||||||
|
"""
|
||||||
|
SRAS Scan Manager
|
||||||
|
Command-line / interactive TUI for inspecting v6 .sras files.
|
||||||
|
|
||||||
|
A .sras file (see scan_format.md) holds one acquisition run across several
|
||||||
|
GR rotation angles, each with its own geometry (x_start, x_delta, n_frames,
|
||||||
|
n_rows) and waveform data block. This tool lists those per-angle sub-scans
|
||||||
|
and lets you export a subset to a new .sras file, or delete a subset from
|
||||||
|
the file in place — both operations rewrite the angle/geometry/row tables
|
||||||
|
and stream-copy only the selected angles' waveform data, producing a file
|
||||||
|
that is itself a valid v6 .sras readable by sras_viewer.py-style tools
|
||||||
|
(once updated for v6) or sc3_aui_app.py.
|
||||||
|
|
||||||
|
Only format version 6 is supported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BLOB_MAGIC = b"SRAS"
|
||||||
|
BLOB_VERSION = 6
|
||||||
|
HDR_FMT = ">4sBHfffffffIdBB"
|
||||||
|
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
|
||||||
|
GEOM_FMT = ">ffIH"
|
||||||
|
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AngleEntry:
|
||||||
|
index: int
|
||||||
|
angle_deg: float
|
||||||
|
x_start: float
|
||||||
|
x_delta: float
|
||||||
|
n_frames: int
|
||||||
|
n_rows_declared: int
|
||||||
|
y_positions: list # declared length; may exceed what's actually on disk
|
||||||
|
row_bytes: int
|
||||||
|
data_offset: int # byte offset into the file where this angle's data starts
|
||||||
|
n_rows_available: int = 0
|
||||||
|
data_size_available: int = 0
|
||||||
|
complete: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_size_declared(self) -> int:
|
||||||
|
return self.row_bytes * self.n_rows_declared
|
||||||
|
|
||||||
|
|
||||||
|
class SrasScanFile:
|
||||||
|
"""Parsed view of a v6 .sras file's header/tables plus per-angle data offsets."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
self._parse()
|
||||||
|
|
||||||
|
def _parse(self):
|
||||||
|
file_size = self.path.stat().st_size
|
||||||
|
with open(self.path, "rb") as f:
|
||||||
|
raw = f.read(HDR_SIZE)
|
||||||
|
if len(raw) < HDR_SIZE:
|
||||||
|
raise ValueError(f"{self.path.name}: file too short for a valid header")
|
||||||
|
(magic, version, n_angles, x_start_nom, y_start_nom, x_delta_nom,
|
||||||
|
y_delta_nom, row_spacing, velocity, laser_freq, samples_per_frame,
|
||||||
|
sample_rate, bytes_per_sample, n_channels) = struct.unpack(HDR_FMT, raw)
|
||||||
|
|
||||||
|
if magic != BLOB_MAGIC:
|
||||||
|
raise ValueError(f"{self.path.name}: bad magic {magic!r}, not a .sras file")
|
||||||
|
if version != BLOB_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.path.name}: unsupported format version {version} "
|
||||||
|
f"(this tool only supports v{BLOB_VERSION})")
|
||||||
|
|
||||||
|
self.x_start_nominal = x_start_nom
|
||||||
|
self.y_start_nominal = y_start_nom
|
||||||
|
self.x_delta_nominal = x_delta_nom
|
||||||
|
self.y_delta_nominal = y_delta_nom
|
||||||
|
self.row_spacing_mm = row_spacing
|
||||||
|
self.velocity_mm_s = velocity
|
||||||
|
self.laser_freq_hz = laser_freq
|
||||||
|
self.samples_per_frame = samples_per_frame
|
||||||
|
self.sample_rate_hz = sample_rate
|
||||||
|
self.bytes_per_sample = bytes_per_sample
|
||||||
|
self.n_channels = n_channels
|
||||||
|
|
||||||
|
angles = list(struct.unpack(f">{n_angles}f", f.read(4 * n_angles)))
|
||||||
|
|
||||||
|
geoms = []
|
||||||
|
for _ in range(n_angles):
|
||||||
|
x_start, x_delta, n_frames, n_rows = struct.unpack(GEOM_FMT, f.read(GEOM_SIZE))
|
||||||
|
geoms.append((x_start, x_delta, n_frames, n_rows))
|
||||||
|
|
||||||
|
row_tables = []
|
||||||
|
for (_, _, _, n_rows) in geoms:
|
||||||
|
row_tables.append(list(struct.unpack(f">{n_rows}f", f.read(4 * n_rows))))
|
||||||
|
|
||||||
|
preambles_raw = []
|
||||||
|
for _ in range(n_channels):
|
||||||
|
(plen,) = struct.unpack(">H", f.read(2))
|
||||||
|
preambles_raw.append(f.read(plen))
|
||||||
|
self.preambles_raw = preambles_raw
|
||||||
|
|
||||||
|
(n_bg,) = struct.unpack(">I", f.read(4))
|
||||||
|
self.background_raw = f.read(n_bg)
|
||||||
|
|
||||||
|
data_start_offset = f.tell()
|
||||||
|
|
||||||
|
# Build angle entries and compute what's actually present on disk,
|
||||||
|
# in case the file was closed early (aborted scan) — see scan_format.md's
|
||||||
|
# "Incomplete files" note. Waveform data is angle-major/row-minor with a
|
||||||
|
# fixed per-row byte count within an angle, so we walk cumulative offsets.
|
||||||
|
self.angles = []
|
||||||
|
cursor = data_start_offset
|
||||||
|
truncated_seen = False
|
||||||
|
for i, (angle, (x_start, x_delta, n_frames, n_rows)) in enumerate(zip(angles, geoms)):
|
||||||
|
row_bytes = n_channels * n_frames * samples_per_frame * bytes_per_sample
|
||||||
|
entry = AngleEntry(
|
||||||
|
index=i, angle_deg=angle, x_start=x_start, x_delta=x_delta,
|
||||||
|
n_frames=n_frames, n_rows_declared=n_rows,
|
||||||
|
y_positions=row_tables[i], row_bytes=row_bytes,
|
||||||
|
data_offset=cursor,
|
||||||
|
)
|
||||||
|
if truncated_seen:
|
||||||
|
entry.n_rows_available = 0
|
||||||
|
entry.data_size_available = 0
|
||||||
|
entry.complete = False
|
||||||
|
else:
|
||||||
|
declared_bytes = row_bytes * n_rows
|
||||||
|
if row_bytes > 0 and cursor + declared_bytes <= file_size:
|
||||||
|
entry.n_rows_available = n_rows
|
||||||
|
entry.data_size_available = declared_bytes
|
||||||
|
entry.complete = True
|
||||||
|
cursor += declared_bytes
|
||||||
|
else:
|
||||||
|
remaining = max(0, file_size - cursor)
|
||||||
|
n_complete = remaining // row_bytes if row_bytes > 0 else 0
|
||||||
|
entry.n_rows_available = n_complete
|
||||||
|
entry.data_size_available = n_complete * row_bytes
|
||||||
|
entry.complete = (n_complete == n_rows)
|
||||||
|
cursor += entry.data_size_available
|
||||||
|
truncated_seen = True
|
||||||
|
self.angles.append(entry)
|
||||||
|
|
||||||
|
self.data_start_offset = data_start_offset
|
||||||
|
self.file_size = file_size
|
||||||
|
|
||||||
|
def get(self, index: int) -> AngleEntry:
|
||||||
|
return self.angles[index]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Export / delete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
||||||
|
"""Write a new v6 .sras file containing only the given angle indices
|
||||||
|
(in the given order). Returns a list of warning strings (e.g. for
|
||||||
|
angles that were truncated on disk and thus exported with fewer rows
|
||||||
|
than declared).
|
||||||
|
"""
|
||||||
|
warnings = []
|
||||||
|
selected = [sf.get(i) for i in indices]
|
||||||
|
|
||||||
|
header = struct.pack(
|
||||||
|
HDR_FMT, BLOB_MAGIC, BLOB_VERSION, len(selected),
|
||||||
|
sf.x_start_nominal, sf.y_start_nominal,
|
||||||
|
sf.x_delta_nominal, sf.y_delta_nominal,
|
||||||
|
sf.row_spacing_mm, sf.velocity_mm_s, sf.laser_freq_hz,
|
||||||
|
sf.samples_per_frame, sf.sample_rate_hz,
|
||||||
|
sf.bytes_per_sample, sf.n_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(sf.path, "rb") as src, open(dst_path, "wb") as dst:
|
||||||
|
dst.write(header)
|
||||||
|
dst.write(struct.pack(f">{len(selected)}f", *[e.angle_deg for e in selected]))
|
||||||
|
|
||||||
|
for e in selected:
|
||||||
|
if e.n_rows_available != e.n_rows_declared:
|
||||||
|
warnings.append(
|
||||||
|
f"angle[{e.index}] ({e.angle_deg:.2f} deg): declared "
|
||||||
|
f"{e.n_rows_declared} rows but only {e.n_rows_available} "
|
||||||
|
f"present on disk — exporting truncated")
|
||||||
|
dst.write(struct.pack(GEOM_FMT, e.x_start, e.x_delta, e.n_frames,
|
||||||
|
e.n_rows_available))
|
||||||
|
|
||||||
|
for e in selected:
|
||||||
|
ys = e.y_positions[:e.n_rows_available]
|
||||||
|
dst.write(struct.pack(f">{len(ys)}f", *ys))
|
||||||
|
|
||||||
|
for praw in sf.preambles_raw:
|
||||||
|
dst.write(struct.pack(">H", len(praw)))
|
||||||
|
dst.write(praw)
|
||||||
|
|
||||||
|
dst.write(struct.pack(">I", len(sf.background_raw)))
|
||||||
|
dst.write(sf.background_raw)
|
||||||
|
|
||||||
|
for e in selected:
|
||||||
|
src.seek(e.data_offset)
|
||||||
|
remaining = e.data_size_available
|
||||||
|
chunk_size = 1 << 20
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = src.read(min(chunk_size, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
dst.write(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def export_angles(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
||||||
|
if not indices:
|
||||||
|
raise ValueError("no angles selected to export")
|
||||||
|
return _write_subset(sf, indices, dst_path)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_angles(sf: SrasScanFile, indices_to_delete: list, backup: bool = True) -> Path | None:
|
||||||
|
"""Rewrite sf.path in place, keeping every angle NOT in indices_to_delete.
|
||||||
|
|
||||||
|
Returns the backup file path if one was made, else None.
|
||||||
|
"""
|
||||||
|
keep = [e.index for e in sf.angles if e.index not in set(indices_to_delete)]
|
||||||
|
if not keep:
|
||||||
|
raise ValueError("refusing to delete every angle — a .sras file needs at least one")
|
||||||
|
|
||||||
|
tmp_path = sf.path.with_suffix(sf.path.suffix + ".tmp")
|
||||||
|
_write_subset(sf, keep, tmp_path)
|
||||||
|
|
||||||
|
backup_path = None
|
||||||
|
if backup:
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
backup_path = sf.path.with_name(f"{sf.path.stem}.bak-{stamp}{sf.path.suffix}")
|
||||||
|
sf.path.rename(backup_path)
|
||||||
|
|
||||||
|
tmp_path.replace(sf.path)
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Formatting helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _human_size(n: int) -> str:
|
||||||
|
size = float(n)
|
||||||
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||||
|
if size < 1024.0:
|
||||||
|
return f"{size:.1f} {unit}"
|
||||||
|
size /= 1024.0
|
||||||
|
return f"{size:.1f} PB"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_index_spec(spec: str, max_index: int) -> list:
|
||||||
|
"""Parse '0,2,4-6' or 'all' into a sorted list of unique in-range indices."""
|
||||||
|
spec = spec.strip().lower()
|
||||||
|
if spec in ("all", "*"):
|
||||||
|
return list(range(max_index + 1))
|
||||||
|
if not spec:
|
||||||
|
return []
|
||||||
|
out = set()
|
||||||
|
for part in spec.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if "-" in part:
|
||||||
|
lo, hi = part.split("-", 1)
|
||||||
|
lo, hi = int(lo), int(hi)
|
||||||
|
if lo > hi:
|
||||||
|
lo, hi = hi, lo
|
||||||
|
for i in range(lo, hi + 1):
|
||||||
|
out.add(i)
|
||||||
|
else:
|
||||||
|
out.add(int(part))
|
||||||
|
bad = [i for i in out if i < 0 or i > max_index]
|
||||||
|
if bad:
|
||||||
|
raise ValueError(f"index out of range (0-{max_index}): {sorted(bad)}")
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def print_summary(sf: SrasScanFile, selected: set):
|
||||||
|
print()
|
||||||
|
print(f"File: {sf.path} (v{BLOB_VERSION}, {_human_size(sf.file_size)})")
|
||||||
|
print(f"Nominal ROI: x_start={sf.x_start_nominal:.4f} x_delta={sf.x_delta_nominal:.4f} "
|
||||||
|
f"y_start={sf.y_start_nominal:.4f} y_delta={sf.y_delta_nominal:.4f} mm "
|
||||||
|
f"row_spacing={sf.row_spacing_mm:.4f} mm")
|
||||||
|
print(f"Velocity={sf.velocity_mm_s:.2f} mm/s Laser={sf.laser_freq_hz:.1f} Hz "
|
||||||
|
f"Samples/frame={sf.samples_per_frame} Sample rate={sf.sample_rate_hz:.3e} Hz "
|
||||||
|
f"Channels={sf.n_channels} Bytes/sample={sf.bytes_per_sample}")
|
||||||
|
print()
|
||||||
|
hdr = f"{'':>2} {'#':>3} {'Angle(deg)':>10} {'Rows':>7} {'Frames/row':>10} {'x_start':>9} {'x_delta':>9} {'Data size':>11} Status"
|
||||||
|
print(hdr)
|
||||||
|
print("-" * len(hdr))
|
||||||
|
for e in sf.angles:
|
||||||
|
mark = "*" if e.index in selected else " "
|
||||||
|
if e.complete:
|
||||||
|
status = "OK"
|
||||||
|
elif e.n_rows_available == 0:
|
||||||
|
status = "MISSING (no data on disk)"
|
||||||
|
else:
|
||||||
|
status = f"TRUNCATED ({e.n_rows_available}/{e.n_rows_declared} rows on disk)"
|
||||||
|
print(f"{mark:>2} {e.index:>3} {e.angle_deg:>10.2f} {e.n_rows_declared:>7} "
|
||||||
|
f"{e.n_frames:>10} {e.x_start:>9.3f} {e.x_delta:>9.3f} "
|
||||||
|
f"{_human_size(e.data_size_available):>11} {status}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Interactive TUI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def interactive_loop(path: Path):
|
||||||
|
sf = SrasScanFile(path)
|
||||||
|
selected: set = set()
|
||||||
|
|
||||||
|
help_text = (
|
||||||
|
" [l] list show the angle table again\n"
|
||||||
|
" [s] select <spec> set selection, e.g. '0,2,4-6' or 'all' or 'none'\n"
|
||||||
|
" [e] export <path> write selected angles to a new .sras file\n"
|
||||||
|
" [d] delete remove selected angles from this file in place\n"
|
||||||
|
" [r] reload re-read the file from disk (after external changes)\n"
|
||||||
|
" [h] help show this help\n"
|
||||||
|
" [q] quit"
|
||||||
|
)
|
||||||
|
|
||||||
|
print_summary(sf, selected)
|
||||||
|
print(help_text)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cmd_line = input("\nsras> ").strip()
|
||||||
|
except EOFError:
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
if not cmd_line:
|
||||||
|
continue
|
||||||
|
parts = cmd_line.split(None, 1)
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if cmd in ("q", "quit", "exit"):
|
||||||
|
break
|
||||||
|
elif cmd in ("h", "help", "?"):
|
||||||
|
print(help_text)
|
||||||
|
elif cmd in ("l", "list"):
|
||||||
|
print_summary(sf, selected)
|
||||||
|
elif cmd in ("s", "select"):
|
||||||
|
if not arg:
|
||||||
|
arg = input("Angles to select (e.g. 0,2,4-6 / all / none): ").strip()
|
||||||
|
if arg.lower() == "none":
|
||||||
|
selected = set()
|
||||||
|
else:
|
||||||
|
selected = set(parse_index_spec(arg, len(sf.angles) - 1))
|
||||||
|
print(f"Selected {len(selected)} angle(s): {sorted(selected)}")
|
||||||
|
elif cmd in ("e", "export"):
|
||||||
|
if not selected:
|
||||||
|
print("Nothing selected — use 's' first.")
|
||||||
|
continue
|
||||||
|
dst = arg or input("Output path: ").strip()
|
||||||
|
if not dst:
|
||||||
|
print("Export cancelled — no path given.")
|
||||||
|
continue
|
||||||
|
dst_path = Path(dst)
|
||||||
|
if dst_path.exists():
|
||||||
|
ans = input(f"{dst_path} exists — overwrite? [y/N] ").strip().lower()
|
||||||
|
if ans != "y":
|
||||||
|
print("Export cancelled.")
|
||||||
|
continue
|
||||||
|
warnings = export_angles(sf, sorted(selected), dst_path)
|
||||||
|
print(f"Exported {len(selected)} angle(s) -> {dst_path}")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" warning: {w}")
|
||||||
|
elif cmd in ("d", "delete"):
|
||||||
|
if not selected:
|
||||||
|
print("Nothing selected — use 's' first.")
|
||||||
|
continue
|
||||||
|
print(f"About to delete {len(selected)} angle(s) from {sf.path}: {sorted(selected)}")
|
||||||
|
ans = input("Type 'yes' to confirm (a timestamped .bak copy will be kept): ").strip()
|
||||||
|
if ans != "yes":
|
||||||
|
print("Delete cancelled.")
|
||||||
|
continue
|
||||||
|
backup_path = delete_angles(sf, sorted(selected), backup=True)
|
||||||
|
print(f"Deleted. Backup saved to {backup_path}")
|
||||||
|
sf = SrasScanFile(sf.path)
|
||||||
|
selected = set()
|
||||||
|
print_summary(sf, selected)
|
||||||
|
elif cmd in ("r", "reload"):
|
||||||
|
sf = SrasScanFile(sf.path)
|
||||||
|
selected = set()
|
||||||
|
print_summary(sf, selected)
|
||||||
|
else:
|
||||||
|
print(f"Unknown command: {cmd!r} (type 'h' for help)")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Error: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Inspect, export, or delete per-angle sub-scans in a v6 .sras file.")
|
||||||
|
ap.add_argument("file", type=Path, help="path to a .sras file")
|
||||||
|
ap.add_argument("--list", action="store_true", help="print the angle table and exit")
|
||||||
|
ap.add_argument("--export", metavar="SPEC", help="angle index spec to export, e.g. '0,2,4-6' or 'all'")
|
||||||
|
ap.add_argument("--output", metavar="PATH", type=Path, help="destination path for --export")
|
||||||
|
ap.add_argument("--delete", metavar="SPEC", help="angle index spec to delete in place, e.g. '1,3'")
|
||||||
|
ap.add_argument("--no-backup", action="store_true", help="skip the .bak copy when using --delete")
|
||||||
|
ap.add_argument("--yes", action="store_true", help="don't prompt for confirmation on --delete")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.file.exists():
|
||||||
|
print(f"error: {args.file} does not exist", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sf = SrasScanFile(args.file)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
non_interactive = args.list or args.export or args.delete
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
print_summary(sf, set())
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.export:
|
||||||
|
indices = parse_index_spec(args.export, len(sf.angles) - 1)
|
||||||
|
if not args.output:
|
||||||
|
print("error: --export requires --output", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if args.output.exists() and not args.yes:
|
||||||
|
ans = input(f"{args.output} exists — overwrite? [y/N] ").strip().lower()
|
||||||
|
if ans != "y":
|
||||||
|
print("Export cancelled.")
|
||||||
|
sys.exit(1)
|
||||||
|
warnings = export_angles(sf, indices, args.output)
|
||||||
|
print(f"Exported {len(indices)} angle(s) -> {args.output}")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" warning: {w}")
|
||||||
|
|
||||||
|
if args.delete:
|
||||||
|
indices = parse_index_spec(args.delete, len(sf.angles) - 1)
|
||||||
|
if not indices:
|
||||||
|
print("error: --delete requires a non-empty angle spec", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not args.yes:
|
||||||
|
print(f"About to delete {len(indices)} angle(s) from {sf.path}: {indices}")
|
||||||
|
ans = input("Type 'yes' to confirm: ").strip()
|
||||||
|
if ans != "yes":
|
||||||
|
print("Delete cancelled.")
|
||||||
|
sys.exit(1)
|
||||||
|
backup_path = delete_angles(sf, indices, backup=not args.no_backup)
|
||||||
|
if backup_path:
|
||||||
|
print(f"Deleted. Backup saved to {backup_path}")
|
||||||
|
else:
|
||||||
|
print("Deleted (no backup kept).")
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not non_interactive:
|
||||||
|
interactive_loop(args.file)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Regular → Executable
Regular → Executable
Executable
+722
@@ -0,0 +1,722 @@
|
|||||||
|
"""T3R Focusing & Rotation Control Panel.
|
||||||
|
|
||||||
|
A user-hidable QDialog that provides full control over the T3R four-channel
|
||||||
|
stepper controller. Mirrors the functionality of /opt/t3r-firmware/tester.py
|
||||||
|
and adds a Stage Rotation section that computes the correct GR-axis step count
|
||||||
|
from the physical gear train.
|
||||||
|
|
||||||
|
Gear train (ch3 = GR-axis):
|
||||||
|
Motor → 10T pinion → 30T idler → 125T index gear (stage)
|
||||||
|
Motor:stage ratio = 125/10 = 12.5
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from t3r_control_panel import T3RControlPanel
|
||||||
|
panel = T3RControlPanel(driver)
|
||||||
|
panel.show()
|
||||||
|
# toggle with panel.setVisible(not panel.isVisible())
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
|
from PyQt6.QtGui import QFont
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFrame, QGridLayout,
|
||||||
|
QGroupBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QScrollArea,
|
||||||
|
QSpinBox, QSplitter, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from hardware.t3r_driver import T3RDriver
|
||||||
|
import hardware.t3r_protocol as proto
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
|
||||||
|
# ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _hline() -> QFrame:
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _mono_font(size: int = 11) -> QFont:
|
||||||
|
f = QFont("Menlo")
|
||||||
|
f.setStyleHint(QFont.StyleHint.Monospace)
|
||||||
|
f.setPointSize(size)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _spin(lo: int, hi: int, val: int) -> QSpinBox:
|
||||||
|
s = QSpinBox()
|
||||||
|
s.setRange(lo, hi)
|
||||||
|
s.setValue(val)
|
||||||
|
s.setGroupSeparatorShown(True)
|
||||||
|
s.setMaximumWidth(130)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-channel panel ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ChannelPanel(QGroupBox):
|
||||||
|
"""Controls and live readouts for one T3R axis."""
|
||||||
|
|
||||||
|
def __init__(self, ch: int, driver: T3RDriver):
|
||||||
|
label = f"Axis {ch} — {T3RDriver.CHANNEL_NAMES[ch]}"
|
||||||
|
super().__init__(label)
|
||||||
|
self.ch = ch
|
||||||
|
self._driver = driver
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
driver.info_updated.connect(self._on_info)
|
||||||
|
driver.drv_status_updated.connect(self._on_drv_status)
|
||||||
|
driver.motion_done.connect(self._on_event_pos)
|
||||||
|
driver.stopped.connect(self._on_event_pos)
|
||||||
|
driver.fault_occurred.connect(self._on_fault_event)
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
grid = QGridLayout(self)
|
||||||
|
grid.setVerticalSpacing(4)
|
||||||
|
grid.setHorizontalSpacing(8)
|
||||||
|
row = 0
|
||||||
|
|
||||||
|
# Enable + state + position
|
||||||
|
self.enable_chk = QCheckBox("Enabled")
|
||||||
|
self.enable_chk.toggled.connect(self._on_enable_toggled)
|
||||||
|
grid.addWidget(self.enable_chk, row, 0)
|
||||||
|
|
||||||
|
self.state_lbl = QLabel("—")
|
||||||
|
self.state_lbl.setMinimumWidth(72)
|
||||||
|
grid.addWidget(self.state_lbl, row, 1)
|
||||||
|
|
||||||
|
lbl = QLabel("pos:")
|
||||||
|
lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
grid.addWidget(lbl, row, 2)
|
||||||
|
self.pos_lbl = QLabel("0")
|
||||||
|
self.pos_lbl.setFont(_mono_font(13))
|
||||||
|
grid.addWidget(self.pos_lbl, row, 3, 1, 2)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
self.fault_lbl = QLabel("faults: none")
|
||||||
|
self.fault_lbl.setStyleSheet("color: #2e7d32;")
|
||||||
|
grid.addWidget(self.fault_lbl, row, 0, 1, 3)
|
||||||
|
self.comms_lbl = QLabel("comms: —")
|
||||||
|
grid.addWidget(self.comms_lbl, row, 3, 1, 2)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(_hline(), row, 0, 1, 5); row += 1
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
grid.addWidget(QLabel("Microsteps"), row, 0)
|
||||||
|
self.micro_combo = QComboBox()
|
||||||
|
for m in proto.MICROSTEPS:
|
||||||
|
self.micro_combo.addItem(str(m), m)
|
||||||
|
self.micro_combo.setCurrentText("16")
|
||||||
|
grid.addWidget(self.micro_combo, row, 1)
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Run mA"), row, 2)
|
||||||
|
self.run_spin = _spin(0, 3000, 800)
|
||||||
|
grid.addWidget(self.run_spin, row, 3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("ihold delay"), row, 0)
|
||||||
|
self.ihold_spin = _spin(0, 15, 6)
|
||||||
|
grid.addWidget(self.ihold_spin, row, 1)
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Hold mA"), row, 2)
|
||||||
|
self.hold_spin = _spin(0, 3000, 400)
|
||||||
|
grid.addWidget(self.hold_spin, row, 3)
|
||||||
|
|
||||||
|
apply_btn = QPushButton("Apply config")
|
||||||
|
apply_btn.setToolTip("Send SET_MICROSTEP + SET_CURRENT (channel must be idle)")
|
||||||
|
apply_btn.clicked.connect(self._on_apply_config)
|
||||||
|
grid.addWidget(apply_btn, row, 4)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
self.achieved_lbl = QLabel("achieved: run —/hold — mA, — µsteps")
|
||||||
|
self.achieved_lbl.setStyleSheet("color: gray;")
|
||||||
|
grid.addWidget(self.achieved_lbl, row, 0, 1, 5)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
self.drv_lbl = QLabel("driver status: —")
|
||||||
|
self.drv_lbl.setStyleSheet("color: gray;")
|
||||||
|
grid.addWidget(self.drv_lbl, row, 0, 1, 5)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(_hline(), row, 0, 1, 5); row += 1
|
||||||
|
|
||||||
|
# Motion parameters
|
||||||
|
grid.addWidget(QLabel("Max vel (steps/s)"), row, 0)
|
||||||
|
self.vel_spin = _spin(1, proto.MAX_VELOCITY, 8000)
|
||||||
|
grid.addWidget(self.vel_spin, row, 1)
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Accel (steps/s²)"), row, 2)
|
||||||
|
self.accel_spin = _spin(0, proto.MAX_ACCEL, 4000)
|
||||||
|
grid.addWidget(self.accel_spin, row, 3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Move
|
||||||
|
grid.addWidget(QLabel("Steps (signed)"), row, 0)
|
||||||
|
self.steps_spin = _spin(-100_000_000, 100_000_000, 3200)
|
||||||
|
grid.addWidget(self.steps_spin, row, 1)
|
||||||
|
move_btn = QPushButton("Move")
|
||||||
|
move_btn.clicked.connect(self._on_move)
|
||||||
|
grid.addWidget(move_btn, row, 2)
|
||||||
|
neg_btn = QPushButton("Move −")
|
||||||
|
neg_btn.clicked.connect(lambda: self._on_move(-1))
|
||||||
|
grid.addWidget(neg_btn, row, 3)
|
||||||
|
pos_btn = QPushButton("Move +")
|
||||||
|
pos_btn.clicked.connect(lambda: self._on_move(+1))
|
||||||
|
grid.addWidget(pos_btn, row, 4)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Jog / stop
|
||||||
|
jogneg = QPushButton("◀ Jog −")
|
||||||
|
jogneg.clicked.connect(lambda: self._on_jog(-1))
|
||||||
|
grid.addWidget(jogneg, row, 0)
|
||||||
|
jogpos = QPushButton("Jog + ▶")
|
||||||
|
jogpos.clicked.connect(lambda: self._on_jog(+1))
|
||||||
|
grid.addWidget(jogpos, row, 1)
|
||||||
|
stop_btn = QPushButton("Stop")
|
||||||
|
stop_btn.clicked.connect(self._on_stop)
|
||||||
|
grid.addWidget(stop_btn, row, 2)
|
||||||
|
self.hard_chk = QCheckBox("hard stop")
|
||||||
|
grid.addWidget(self.hard_chk, row, 3, 1, 2)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(_hline(), row, 0, 1, 5); row += 1
|
||||||
|
|
||||||
|
# Position utilities
|
||||||
|
grid.addWidget(QLabel("Set pos"), row, 0)
|
||||||
|
self.setpos_spin = _spin(-100_000_000, 100_000_000, 0)
|
||||||
|
grid.addWidget(self.setpos_spin, row, 1)
|
||||||
|
setpos_btn = QPushButton("Set")
|
||||||
|
setpos_btn.clicked.connect(self._on_set_position)
|
||||||
|
grid.addWidget(setpos_btn, row, 2)
|
||||||
|
zero_btn = QPushButton("Zero")
|
||||||
|
zero_btn.clicked.connect(lambda: self._driver.set_position(self.ch, 0))
|
||||||
|
grid.addWidget(zero_btn, row, 3)
|
||||||
|
drvst_btn = QPushButton("Driver status")
|
||||||
|
drvst_btn.setToolTip("GET_DRV_STATUS — SPI read, only works while idle")
|
||||||
|
drvst_btn.clicked.connect(lambda: self._driver.get_drv_status(self.ch))
|
||||||
|
grid.addWidget(drvst_btn, row, 4)
|
||||||
|
|
||||||
|
grid.setColumnStretch(4, 1)
|
||||||
|
|
||||||
|
# ── Commands ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_enable_toggled(self, checked: bool):
|
||||||
|
if checked:
|
||||||
|
self._driver.enable(self.ch)
|
||||||
|
else:
|
||||||
|
self._driver.disable(self.ch)
|
||||||
|
|
||||||
|
def _on_apply_config(self):
|
||||||
|
micro = self.micro_combo.currentData()
|
||||||
|
self._driver.set_microstep(self.ch, micro)
|
||||||
|
self._driver.set_current(self.ch, self.run_spin.value(),
|
||||||
|
self.hold_spin.value(), self.ihold_spin.value())
|
||||||
|
|
||||||
|
def _on_move(self, force_sign: int = 0):
|
||||||
|
steps = self.steps_spin.value()
|
||||||
|
if force_sign:
|
||||||
|
steps = force_sign * abs(steps)
|
||||||
|
self._driver.move(self.ch, steps, self.vel_spin.value(), self.accel_spin.value())
|
||||||
|
|
||||||
|
def _on_jog(self, direction: int):
|
||||||
|
self._driver.jog(self.ch, direction * self.vel_spin.value(), self.accel_spin.value())
|
||||||
|
|
||||||
|
def _on_stop(self):
|
||||||
|
self._driver.stop(self.ch, self.hard_chk.isChecked())
|
||||||
|
|
||||||
|
def _on_set_position(self):
|
||||||
|
self._driver.set_position(self.ch, self.setpos_spin.value())
|
||||||
|
|
||||||
|
# ── Incoming updates ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_info(self, ch: int, info):
|
||||||
|
if ch != self.ch:
|
||||||
|
return
|
||||||
|
self.state_lbl.setText(proto.STATE_NAMES.get(info.state, "?"))
|
||||||
|
self.pos_lbl.setText(f"{info.position:,}")
|
||||||
|
self.achieved_lbl.setText(
|
||||||
|
f"achieved: run {info.run_ma}/hold {info.hold_ma} mA, {info.microsteps} µsteps")
|
||||||
|
self.comms_lbl.setText("comms: OK" if info.comms_ok else "comms: FAIL")
|
||||||
|
self.comms_lbl.setStyleSheet("" if info.comms_ok else "color: #c0392b;")
|
||||||
|
self._set_fault(info.fault_mask)
|
||||||
|
if self.enable_chk.isChecked() != info.enabled:
|
||||||
|
self.enable_chk.blockSignals(True)
|
||||||
|
self.enable_chk.setChecked(info.enabled)
|
||||||
|
self.enable_chk.blockSignals(False)
|
||||||
|
|
||||||
|
def _on_drv_status(self, ch: int, st):
|
||||||
|
if ch != self.ch:
|
||||||
|
return
|
||||||
|
self._set_fault(st.fault_mask)
|
||||||
|
self.drv_lbl.setText(
|
||||||
|
f"driver status: CS_ACTUAL={st.cs_actual} SG_RESULT={st.sg_result} "
|
||||||
|
f"{'standstill' if st.standstill else 'moving'} raw=0x{st.raw:08X}")
|
||||||
|
|
||||||
|
def _on_event_pos(self, ch: int, position: int):
|
||||||
|
if ch == self.ch:
|
||||||
|
self.pos_lbl.setText(f"{position:,}")
|
||||||
|
|
||||||
|
def _on_fault_event(self, ch: int, mask: int):
|
||||||
|
if ch == self.ch:
|
||||||
|
self._set_fault(mask)
|
||||||
|
|
||||||
|
def _set_fault(self, mask: int):
|
||||||
|
names = proto.fault_names(mask)
|
||||||
|
self.fault_lbl.setText(f"faults: {names}")
|
||||||
|
self.fault_lbl.setStyleSheet(
|
||||||
|
"color: #c0392b; font-weight: bold;" if mask else "color: #2e7d32;")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ganged motion panel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class GroupPanel(QGroupBox):
|
||||||
|
"""Ganged motion — selected axes step in lockstep."""
|
||||||
|
|
||||||
|
def __init__(self, driver: T3RDriver, log_fn):
|
||||||
|
super().__init__("Ganged / synchronised motion — selected axes move in lockstep")
|
||||||
|
self._driver = driver
|
||||||
|
self._log = log_fn
|
||||||
|
self._axis_chks: list[QCheckBox] = []
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
grid = QGridLayout(self)
|
||||||
|
grid.setVerticalSpacing(4)
|
||||||
|
grid.setHorizontalSpacing(8)
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Axes:"), 0, 0)
|
||||||
|
axis_box = QHBoxLayout()
|
||||||
|
for i in range(proto.NUM_CHANNELS):
|
||||||
|
chk = QCheckBox(str(i))
|
||||||
|
self._axis_chks.append(chk)
|
||||||
|
axis_box.addWidget(chk)
|
||||||
|
axis_box.addStretch(1)
|
||||||
|
holder = QWidget()
|
||||||
|
holder.setLayout(axis_box)
|
||||||
|
grid.addWidget(holder, 0, 1, 1, 3)
|
||||||
|
|
||||||
|
hold_btn = QPushButton("Energise selected")
|
||||||
|
hold_btn.clicked.connect(self._on_energise)
|
||||||
|
grid.addWidget(hold_btn, 0, 4)
|
||||||
|
rel_btn = QPushButton("Release all")
|
||||||
|
rel_btn.clicked.connect(lambda: self._driver.enable_mask(0))
|
||||||
|
grid.addWidget(rel_btn, 0, 5)
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Max vel (steps/s)"), 1, 0)
|
||||||
|
self.vel_spin = _spin(1, proto.MAX_VELOCITY, 8000)
|
||||||
|
grid.addWidget(self.vel_spin, 1, 1)
|
||||||
|
grid.addWidget(QLabel("Accel (steps/s²)"), 1, 2)
|
||||||
|
self.accel_spin = _spin(0, proto.MAX_ACCEL, 4000)
|
||||||
|
grid.addWidget(self.accel_spin, 1, 3)
|
||||||
|
grid.addWidget(QLabel("Steps (signed)"), 1, 4)
|
||||||
|
self.steps_spin = _spin(-100_000_000, 100_000_000, 3200)
|
||||||
|
grid.addWidget(self.steps_spin, 1, 5)
|
||||||
|
|
||||||
|
move_btn = QPushButton("Move group")
|
||||||
|
move_btn.clicked.connect(lambda: self._on_move())
|
||||||
|
grid.addWidget(move_btn, 2, 0)
|
||||||
|
neg_btn = QPushButton("Move −")
|
||||||
|
neg_btn.clicked.connect(lambda: self._on_move(-1))
|
||||||
|
grid.addWidget(neg_btn, 2, 1)
|
||||||
|
pos_btn = QPushButton("Move +")
|
||||||
|
pos_btn.clicked.connect(lambda: self._on_move(+1))
|
||||||
|
grid.addWidget(pos_btn, 2, 2)
|
||||||
|
jogneg = QPushButton("◀ Jog −")
|
||||||
|
jogneg.clicked.connect(lambda: self._on_jog(-1))
|
||||||
|
grid.addWidget(jogneg, 2, 3)
|
||||||
|
jogpos = QPushButton("Jog + ▶")
|
||||||
|
jogpos.clicked.connect(lambda: self._on_jog(+1))
|
||||||
|
grid.addWidget(jogpos, 2, 4)
|
||||||
|
stop_btn = QPushButton("Stop group")
|
||||||
|
stop_btn.clicked.connect(self._on_stop)
|
||||||
|
grid.addWidget(stop_btn, 2, 5)
|
||||||
|
|
||||||
|
self.hard_chk = QCheckBox("hard stop")
|
||||||
|
grid.addWidget(self.hard_chk, 3, 5)
|
||||||
|
grid.setColumnStretch(3, 1)
|
||||||
|
|
||||||
|
def _mask(self) -> int:
|
||||||
|
return sum((1 << i) for i, chk in enumerate(self._axis_chks) if chk.isChecked())
|
||||||
|
|
||||||
|
def _require_mask(self) -> int | None:
|
||||||
|
mask = self._mask()
|
||||||
|
if not mask:
|
||||||
|
self._log("No axes selected for ganged motion", "err")
|
||||||
|
return None
|
||||||
|
return mask
|
||||||
|
|
||||||
|
def _on_energise(self):
|
||||||
|
mask = self._require_mask()
|
||||||
|
if mask is not None:
|
||||||
|
self._driver.enable_mask(mask)
|
||||||
|
|
||||||
|
def _on_move(self, force_sign: int = 0):
|
||||||
|
mask = self._require_mask()
|
||||||
|
if mask is None:
|
||||||
|
return
|
||||||
|
steps = self.steps_spin.value()
|
||||||
|
if force_sign:
|
||||||
|
steps = force_sign * abs(steps)
|
||||||
|
self._driver.move_group(mask, steps, self.vel_spin.value(), self.accel_spin.value())
|
||||||
|
|
||||||
|
def _on_jog(self, direction: int):
|
||||||
|
mask = self._require_mask()
|
||||||
|
if mask is None:
|
||||||
|
return
|
||||||
|
self._driver.jog_group(mask, direction * self.vel_spin.value(), self.accel_spin.value())
|
||||||
|
|
||||||
|
def _on_stop(self):
|
||||||
|
mask = self._require_mask()
|
||||||
|
if mask is None:
|
||||||
|
return
|
||||||
|
ch = (mask & -mask).bit_length() - 1
|
||||||
|
self._driver.stop(ch, self.hard_chk.isChecked())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rotation panel ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RotationPanel(QGroupBox):
|
||||||
|
"""Stage rotation via GR-axis (ch3).
|
||||||
|
|
||||||
|
Computes the GR-axis step count from the physical gear train:
|
||||||
|
Motor → 10T pinion → 30T idler → 125T index gear (stage)
|
||||||
|
Ratio = 125/10 = 12.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, driver: T3RDriver):
|
||||||
|
super().__init__(
|
||||||
|
f"Stage Rotation (GR-axis ch{T3RDriver.GR_AXIS_CH}) — "
|
||||||
|
f"gear: {T3RDriver.GEAR_TEETH_MOTOR}T motor → 30T idler → "
|
||||||
|
f"{T3RDriver.GEAR_TEETH_STAGE}T stage "
|
||||||
|
f"= {T3RDriver.GEAR_TEETH_STAGE}/{T3RDriver.GEAR_TEETH_MOTOR} ratio"
|
||||||
|
)
|
||||||
|
self._driver = driver
|
||||||
|
self._gr_microsteps = 16 # updated from info_updated signal
|
||||||
|
self._build()
|
||||||
|
driver.info_updated.connect(self._on_info)
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
grid = QGridLayout(self)
|
||||||
|
grid.setVerticalSpacing(4)
|
||||||
|
grid.setHorizontalSpacing(8)
|
||||||
|
|
||||||
|
# Microsteps (read-only, tracked from driver)
|
||||||
|
grid.addWidget(QLabel("GR-axis µsteps:"), 0, 0)
|
||||||
|
self.microstep_lbl = QLabel("16 (live from device)")
|
||||||
|
self.microstep_lbl.setStyleSheet("color: gray;")
|
||||||
|
grid.addWidget(self.microstep_lbl, 0, 1)
|
||||||
|
|
||||||
|
# Angle input
|
||||||
|
grid.addWidget(QLabel("Rotate by (°):"), 0, 2)
|
||||||
|
self.angle_spin = QDoubleSpinBox()
|
||||||
|
self.angle_spin.setRange(-360.0, 360.0)
|
||||||
|
self.angle_spin.setSingleStep(1.0)
|
||||||
|
self.angle_spin.setDecimals(3)
|
||||||
|
self.angle_spin.setValue(90.0)
|
||||||
|
self.angle_spin.valueChanged.connect(self._update_steps_display)
|
||||||
|
grid.addWidget(self.angle_spin, 0, 3)
|
||||||
|
|
||||||
|
self.steps_lbl = QLabel("= — steps")
|
||||||
|
self.steps_lbl.setFont(_mono_font(11))
|
||||||
|
grid.addWidget(self.steps_lbl, 0, 4)
|
||||||
|
|
||||||
|
# Velocity / accel
|
||||||
|
grid.addWidget(QLabel("Velocity (steps/s):"), 1, 0)
|
||||||
|
self.vel_spin = _spin(1, proto.MAX_VELOCITY, 8000)
|
||||||
|
grid.addWidget(self.vel_spin, 1, 1)
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Accel (steps/s²):"), 1, 2)
|
||||||
|
self.accel_spin = _spin(0, proto.MAX_ACCEL, 4000)
|
||||||
|
grid.addWidget(self.accel_spin, 1, 3)
|
||||||
|
|
||||||
|
# Preset angles for N-angle scans
|
||||||
|
grid.addWidget(QLabel("Quick presets:"), 2, 0)
|
||||||
|
presets = QHBoxLayout()
|
||||||
|
for n in (2, 4, 6, 8, 12):
|
||||||
|
btn = QPushButton(f"360/{n}")
|
||||||
|
btn.setToolTip(f"{360/n:.3f}° — rotate for {n}-angle scan")
|
||||||
|
btn.clicked.connect(lambda _, ang=360.0/n: self.angle_spin.setValue(ang))
|
||||||
|
presets.addWidget(btn)
|
||||||
|
presets.addStretch(1)
|
||||||
|
presets_w = QWidget()
|
||||||
|
presets_w.setLayout(presets)
|
||||||
|
grid.addWidget(presets_w, 2, 1, 1, 3)
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
rotate_btn = QPushButton("Rotate Stage")
|
||||||
|
rotate_btn.setStyleSheet(
|
||||||
|
"QPushButton { background: #1565c0; color: white; font-weight: bold; padding: 4px 14px; }"
|
||||||
|
"QPushButton:disabled { background: #90a4ae; }")
|
||||||
|
rotate_btn.clicked.connect(self._on_rotate)
|
||||||
|
grid.addWidget(rotate_btn, 2, 4)
|
||||||
|
|
||||||
|
grid.setColumnStretch(4, 1)
|
||||||
|
self._update_steps_display()
|
||||||
|
|
||||||
|
def _on_info(self, ch: int, info):
|
||||||
|
if ch != T3RDriver.GR_AXIS_CH:
|
||||||
|
return
|
||||||
|
self._gr_microsteps = info.microsteps
|
||||||
|
self.microstep_lbl.setText(f"{info.microsteps} µsteps (from device)")
|
||||||
|
self._update_steps_display()
|
||||||
|
|
||||||
|
def _update_steps_display(self):
|
||||||
|
steps = self._driver.steps_for_angle(self.angle_spin.value(), self._gr_microsteps)
|
||||||
|
self.steps_lbl.setText(f"= {steps:,} steps")
|
||||||
|
|
||||||
|
def _on_rotate(self):
|
||||||
|
self._driver.rotate_stage(
|
||||||
|
self.angle_spin.value(), self._gr_microsteps,
|
||||||
|
self.vel_spin.value(), self.accel_spin.value())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main dialog ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class T3RControlPanel(QDialog):
|
||||||
|
"""User-hidable T3R control window.
|
||||||
|
|
||||||
|
Pass a T3RDriver instance. The panel connects to its signals and forwards
|
||||||
|
commands via its API. Connection management (port open/close) is handled
|
||||||
|
inside the panel itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, driver: T3RDriver, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("T3R Stepper Controller")
|
||||||
|
self.setWindowFlags(
|
||||||
|
Qt.WindowType.Window
|
||||||
|
| Qt.WindowType.WindowCloseButtonHint
|
||||||
|
| Qt.WindowType.WindowMinimizeButtonHint
|
||||||
|
)
|
||||||
|
self._driver = driver
|
||||||
|
self._build()
|
||||||
|
self._connect_driver_signals()
|
||||||
|
self._set_controls_enabled(False)
|
||||||
|
|
||||||
|
# ── Construction ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
|
||||||
|
outer.addLayout(self._build_connection_bar())
|
||||||
|
|
||||||
|
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||||
|
|
||||||
|
panels_host = QWidget()
|
||||||
|
vbox = QVBoxLayout(panels_host)
|
||||||
|
self.group_panel = GroupPanel(self._driver, self._log)
|
||||||
|
vbox.addWidget(self.group_panel)
|
||||||
|
|
||||||
|
grid_holder = QWidget()
|
||||||
|
grid = QGridLayout(grid_holder)
|
||||||
|
grid.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.channel_panels: list[ChannelPanel] = []
|
||||||
|
for ch in range(proto.NUM_CHANNELS):
|
||||||
|
p = ChannelPanel(ch, self._driver)
|
||||||
|
self.channel_panels.append(p)
|
||||||
|
grid.addWidget(p, ch // 2, ch % 2)
|
||||||
|
vbox.addWidget(grid_holder)
|
||||||
|
|
||||||
|
self.rotation_panel = RotationPanel(self._driver)
|
||||||
|
vbox.addWidget(self.rotation_panel)
|
||||||
|
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setWidget(panels_host)
|
||||||
|
splitter.addWidget(scroll)
|
||||||
|
|
||||||
|
splitter.addWidget(self._build_log())
|
||||||
|
splitter.setStretchFactor(0, 3)
|
||||||
|
splitter.setStretchFactor(1, 1)
|
||||||
|
outer.addWidget(splitter, 1)
|
||||||
|
|
||||||
|
self.resize(1100, 950)
|
||||||
|
|
||||||
|
def _build_connection_bar(self) -> QHBoxLayout:
|
||||||
|
bar = QHBoxLayout()
|
||||||
|
bar.addWidget(QLabel("Port:"))
|
||||||
|
self.port_combo = QComboBox()
|
||||||
|
self.port_combo.setMinimumWidth(280)
|
||||||
|
bar.addWidget(self.port_combo)
|
||||||
|
|
||||||
|
refresh_btn = QPushButton("⟳")
|
||||||
|
refresh_btn.setMaximumWidth(36)
|
||||||
|
refresh_btn.setToolTip("Rescan serial ports")
|
||||||
|
refresh_btn.clicked.connect(self._refresh_ports)
|
||||||
|
bar.addWidget(refresh_btn)
|
||||||
|
|
||||||
|
self.connect_btn = QPushButton("Connect")
|
||||||
|
self.connect_btn.clicked.connect(self._toggle_connect)
|
||||||
|
bar.addWidget(self.connect_btn)
|
||||||
|
|
||||||
|
self.conn_lbl = QLabel("disconnected")
|
||||||
|
bar.addWidget(self.conn_lbl)
|
||||||
|
bar.addStretch(1)
|
||||||
|
|
||||||
|
self.ping_btn = QPushButton("Ping")
|
||||||
|
self.ping_btn.setEnabled(False)
|
||||||
|
self.ping_btn.clicked.connect(self._driver.ping)
|
||||||
|
bar.addWidget(self.ping_btn)
|
||||||
|
|
||||||
|
self.fw_lbl = QLabel("")
|
||||||
|
bar.addWidget(self.fw_lbl)
|
||||||
|
|
||||||
|
self.stopall_btn = QPushButton("STOP ALL")
|
||||||
|
self.stopall_btn.setEnabled(False)
|
||||||
|
self.stopall_btn.setStyleSheet(
|
||||||
|
"QPushButton { background:#c0392b; color:white; font-weight:bold; padding:4px 14px; }"
|
||||||
|
"QPushButton:disabled { background:#e8a39b; }")
|
||||||
|
self.stopall_btn.clicked.connect(self._driver.stop_all)
|
||||||
|
bar.addWidget(self.stopall_btn)
|
||||||
|
return bar
|
||||||
|
|
||||||
|
def _build_log(self) -> QWidget:
|
||||||
|
box = QWidget()
|
||||||
|
v = QVBoxLayout(box)
|
||||||
|
v.setContentsMargins(0, 0, 0, 0)
|
||||||
|
head = QHBoxLayout()
|
||||||
|
head.addWidget(QLabel("Log"))
|
||||||
|
head.addStretch(1)
|
||||||
|
self.rawlog_chk = QCheckBox("Log raw frames")
|
||||||
|
head.addWidget(self.rawlog_chk)
|
||||||
|
clear_btn = QPushButton("Clear")
|
||||||
|
clear_btn.clicked.connect(lambda: self.log_view.clear())
|
||||||
|
head.addWidget(clear_btn)
|
||||||
|
v.addLayout(head)
|
||||||
|
self.log_view = QPlainTextEdit()
|
||||||
|
self.log_view.setReadOnly(True)
|
||||||
|
self.log_view.setMaximumBlockCount(2000)
|
||||||
|
self.log_view.setFont(_mono_font(11))
|
||||||
|
v.addWidget(self.log_view)
|
||||||
|
return box
|
||||||
|
|
||||||
|
# ── Driver signal wiring ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _connect_driver_signals(self):
|
||||||
|
self._driver.port_opened.connect(self._on_port_opened)
|
||||||
|
self._driver.handshake_ok.connect(self._on_handshake_ok)
|
||||||
|
self._driver.disconnected.connect(self._on_disconnected)
|
||||||
|
self._driver.ack_received.connect(self._on_ack)
|
||||||
|
self._driver.motion_done.connect(self._on_motion_done)
|
||||||
|
self._driver.stopped.connect(self._on_stopped)
|
||||||
|
self._driver.fault_occurred.connect(self._on_fault)
|
||||||
|
self._driver.frame_received.connect(self._on_raw_frame)
|
||||||
|
|
||||||
|
# ── Connection control ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _refresh_ports(self):
|
||||||
|
current = self.port_combo.currentText()
|
||||||
|
self.port_combo.clear()
|
||||||
|
ports = list(serial.tools.list_ports.comports())
|
||||||
|
|
||||||
|
def score(p):
|
||||||
|
text = f"{p.description} {p.manufacturer or ''} {p.product or ''}".lower()
|
||||||
|
hints = ("esp32", "jtag", "espressif", "usb serial", "cp210", "ch340", "cdc")
|
||||||
|
return -sum(h in text for h in hints)
|
||||||
|
|
||||||
|
ports.sort(key=score)
|
||||||
|
for p in ports:
|
||||||
|
self.port_combo.addItem(f"{p.device} — {p.description or p.device}", p.device)
|
||||||
|
if self.port_combo.count() == 0:
|
||||||
|
self.port_combo.addItem("(no serial ports found)", None)
|
||||||
|
elif current:
|
||||||
|
idx = self.port_combo.findText(current, Qt.MatchFlag.MatchStartsWith)
|
||||||
|
if idx >= 0:
|
||||||
|
self.port_combo.setCurrentIndex(idx)
|
||||||
|
|
||||||
|
def _toggle_connect(self):
|
||||||
|
if self._driver.is_open:
|
||||||
|
self._driver.disconnect()
|
||||||
|
return
|
||||||
|
port = self.port_combo.currentData()
|
||||||
|
if not port:
|
||||||
|
self._log("No serial port selected", "err")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._driver.connect(port)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f"Connect failed: {exc}", "err")
|
||||||
|
self.conn_lbl.setText("connect failed")
|
||||||
|
|
||||||
|
# ── Driver event handlers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_port_opened(self):
|
||||||
|
self.conn_lbl.setText("opening…")
|
||||||
|
self.connect_btn.setText("Disconnect")
|
||||||
|
self.port_combo.setEnabled(False)
|
||||||
|
self._log(f"Port opened, sending PING…", "evt")
|
||||||
|
|
||||||
|
def _on_handshake_ok(self, proto_ver: int, fw_ver: int, num_ch: int):
|
||||||
|
self.conn_lbl.setText("connected")
|
||||||
|
self.fw_lbl.setText(
|
||||||
|
f"proto v{proto_ver}, fw {fw_ver >> 8}.{fw_ver & 0xFF}, {num_ch} ch")
|
||||||
|
self._log(f"PONG: proto v{proto_ver}, fw 0x{fw_ver:04X}, {num_ch} ch", "rx")
|
||||||
|
self._set_controls_enabled(True)
|
||||||
|
|
||||||
|
def _on_disconnected(self, reason: str):
|
||||||
|
self.conn_lbl.setText(f"disconnected ({reason})" if reason else "disconnected")
|
||||||
|
self.connect_btn.setText("Connect")
|
||||||
|
self.port_combo.setEnabled(True)
|
||||||
|
self.fw_lbl.setText("")
|
||||||
|
self._set_controls_enabled(False)
|
||||||
|
if reason:
|
||||||
|
self._log(f"Disconnected: {reason}", "err")
|
||||||
|
else:
|
||||||
|
self._log("Disconnected", "evt")
|
||||||
|
|
||||||
|
def _on_ack(self, req_cmd: int, status: int):
|
||||||
|
name = proto.CMD_NAMES.get(req_cmd, f"0x{req_cmd:02X}")
|
||||||
|
status_name = proto.STATUS_NAMES.get(status, f"0x{status:02X}")
|
||||||
|
if status != 0:
|
||||||
|
self._log(f"ACK {name} → {status_name}", "rx")
|
||||||
|
elif self.rawlog_chk.isChecked():
|
||||||
|
self._log(f"ACK {name} → OK", "rx")
|
||||||
|
|
||||||
|
def _on_motion_done(self, ch: int, position: int):
|
||||||
|
name = T3RDriver.CHANNEL_NAMES[ch] if ch < len(T3RDriver.CHANNEL_NAMES) else f"ch{ch}"
|
||||||
|
self._log(f"MOTION_DONE {name} @ {position:,}", "evt")
|
||||||
|
|
||||||
|
def _on_stopped(self, ch: int, position: int):
|
||||||
|
name = T3RDriver.CHANNEL_NAMES[ch] if ch < len(T3RDriver.CHANNEL_NAMES) else f"ch{ch}"
|
||||||
|
self._log(f"STOPPED {name} @ {position:,}", "evt")
|
||||||
|
|
||||||
|
def _on_fault(self, ch: int, mask: int):
|
||||||
|
name = T3RDriver.CHANNEL_NAMES[ch] if ch < len(T3RDriver.CHANNEL_NAMES) else f"ch{ch}"
|
||||||
|
self._log(f"FAULT {name}: {proto.fault_names(mask)}", "err")
|
||||||
|
|
||||||
|
def _on_raw_frame(self, cmd: int, payload: bytes):
|
||||||
|
if self.rawlog_chk.isChecked():
|
||||||
|
frame = proto.build_frame(cmd, payload)
|
||||||
|
self._log(frame.hex(" "), "rx")
|
||||||
|
|
||||||
|
def _set_controls_enabled(self, on: bool):
|
||||||
|
self.ping_btn.setEnabled(on)
|
||||||
|
self.stopall_btn.setEnabled(on)
|
||||||
|
self.group_panel.setEnabled(on)
|
||||||
|
self.rotation_panel.setEnabled(on)
|
||||||
|
for p in self.channel_panels:
|
||||||
|
p.setEnabled(on)
|
||||||
|
|
||||||
|
# ── Logging ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _log(self, msg: str, kind: str = ""):
|
||||||
|
prefix = {"tx": "→ ", "rx": "← ", "evt": "● ", "err": "! "}.get(kind, " ")
|
||||||
|
self.log_view.appendPlainText(prefix + msg)
|
||||||
|
|
||||||
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
super().showEvent(event)
|
||||||
|
if self.port_combo.count() == 0:
|
||||||
|
self._refresh_ports()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
# Hide instead of destroy so the window can be re-shown.
|
||||||
|
event.ignore()
|
||||||
|
self.hide()
|
||||||
Regular → Executable
Reference in New Issue
Block a user