3 Commits

Author SHA1 Message Date
Thomas Ales 7bcdff9756 Remove nonessential files
- photorec.ses / photorec.se2: accidentally committed PhotoRec
  data-recovery session files
- sc3-aui-focusing.py: dead module — imports a T3RStepperDriver that
  doesn't exist anywhere; superseded by t3r_control_panel.py
- adc_bug.md: stale debugging note for an already-applied fix
- app_style.qss: empty stylesheet; app.py already handles its absence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:27:30 -05:00
Thomas Ales 2041fc8439 pre merge cleanup commit 2026-07-28 09:20:58 -05:00
Thomas Ales [MSE] 1014533626 precommit 2026-07-21 20:10:06 -05:00
53 changed files with 3011 additions and 582 deletions
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
-37
View File
@@ -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
Regular → Executable
+74 -4
View File
@@ -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:
View File
Regular → Executable
+3 -3
View File
@@ -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
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
Regular → Executable
+1
View File
@@ -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
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
Regular → Executable
View File
View File
+287
View File
@@ -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)
+374
View File
@@ -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
View File
Regular → Executable
+153 -20
View File
@@ -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
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+98 -8
View File
@@ -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
View File
@@ -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
View File
+669 -365
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+84 -86
View File
@@ -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
View File
Regular → Executable
View File
View File
+471
View File
@@ -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
View File
Regular → Executable
View File
+722
View File
@@ -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
View File