afe33249d1
The headline of the refactor. Scan orchestration no longer lives inside a QObject that reaches through Qt workers for its hardware handles. core/scan_engine.py — ScanEngine(stage, scope, rotator, plan, out_path, resume, callbacks). Takes the concrete drivers, blocks in run(), reports via plain callables, and prompts through an injected blocking callable. No Qt import anywhere in the path (test_engine_imports_without_qt proves it), so a simpler GUI or a CLI can drive the identical acquisition. Supporting extractions, all Qt-free: - core/scope_sras.py — SCPI policy: channel profiles, trigger programming, background average, per-row FastFrame transfer - core/rotation.py — RotationAxis + RotationSettings (the GR_* constants) - core/scan_resume.py — frontier contiguity rule + settings compatibility - gui/scan_bridge.py — QtScanController, exposing exactly the signal surface the old ScanWorker had, so MainWindow's connections are unchanged hardware/t3r_driver.py is now Qt-free: a plain Signal class, a threading reader, and a polling thread instead of QObject/QThread/QTimer. gui/qt_t3r.py re-emits its callbacks as queued Qt signals for the panels. Fixes carried by the extraction: - rotation waits on the driver's MOTION_DONE event instead of time.sleep(estimate + 0.5) - abort during an operator prompt now takes effect; the old _prompt_event.wait() had no timeout and could not be interrupted - the poll timer is a thread, so an I/O error tearing down the driver no longer calls QTimer.stop() from the wrong thread - T3RDriver.disconnect() renamed close(); it shadowed QObject.disconnect() - per-frame DC means use np.frombuffer over the joined block instead of struct.unpack per frame (~16k tuple allocations per row) tests/fakes.py + test_scan_engine.py (14 tests) assert the exact command sequence, file layout, resume seeking, abort/pause, and geometry rejection before any hardware call; test_scan_resume.py covers the frontier rule. 58 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
13 KiB
Python
Executable File
344 lines
13 KiB
Python
Executable File
"""T3R Stepper Controller driver for ScanEngine-3.
|
|
|
|
Owns the serial connection and an internal reader thread. Events are
|
|
delivered as plain-Python callbacks (see ``Signal``); commands are
|
|
fire-and-forget writes. No Qt — GUIs wrap this with gui.qt_t3r.QtT3RAdapter,
|
|
which re-emits every event as a queued Qt signal on the GUI thread.
|
|
|
|
Callbacks run on the reader thread. Keep them short, and never touch Qt
|
|
widgets from one directly.
|
|
|
|
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 logging
|
|
import threading
|
|
|
|
from . import t3r_protocol as proto
|
|
from .serial_util import open_8n1
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Signal:
|
|
"""Minimal observer slot: ``connect(fn)`` then ``emit(*args)``.
|
|
|
|
Mirrors the pyqtSignal API used by the existing panels so the same call
|
|
sites work against either this driver or a Qt adapter over it. A raising
|
|
subscriber is logged and skipped so one bad listener cannot kill the
|
|
reader thread.
|
|
"""
|
|
|
|
__slots__ = ("_subs", "_name")
|
|
|
|
def __init__(self, name: str = ""):
|
|
self._subs: list = []
|
|
self._name = name
|
|
|
|
def connect(self, fn) -> None:
|
|
self._subs.append(fn)
|
|
|
|
def disconnect(self, fn) -> None:
|
|
if fn in self._subs:
|
|
self._subs.remove(fn)
|
|
|
|
def emit(self, *args) -> None:
|
|
for fn in list(self._subs):
|
|
try:
|
|
fn(*args)
|
|
except Exception:
|
|
logger.exception("T3R %s subscriber failed", self._name)
|
|
|
|
|
|
class _Reader(threading.Thread):
|
|
"""Blocking read loop — decodes frames and hands them to `on_frame`."""
|
|
|
|
def __init__(self, ser, on_frame, on_finished):
|
|
super().__init__(daemon=True, name="T3RReader")
|
|
self._ser = ser
|
|
self._on_frame = on_frame
|
|
self._on_finished = on_finished
|
|
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._on_frame(cmd, payload)
|
|
self._on_finished(reason)
|
|
|
|
|
|
class T3RDriver:
|
|
"""Driver for the T3R four-channel stepper controller.
|
|
|
|
Usage::
|
|
|
|
driver = T3RDriver()
|
|
driver.handshake_ok.connect(lambda pv, fw, nc: ...)
|
|
driver.open("/dev/ttyUSB0")
|
|
driver.move(0, steps=3200, velocity=8000, accel=4000)
|
|
driver.wait_motion_done(0, timeout=30.0)
|
|
"""
|
|
|
|
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
|
|
|
|
POLL_INTERVAL_S = 0.25
|
|
|
|
def __init__(self):
|
|
self._ser = None
|
|
self._reader: _Reader | None = None
|
|
self._write_lock = threading.Lock()
|
|
self._tearing_down = False
|
|
self._is_open = False
|
|
|
|
self._poll_stop = threading.Event()
|
|
self._poll_thread: threading.Thread | None = None
|
|
|
|
# Per-channel motion-completion events, so a caller can block on a
|
|
# move finishing instead of guessing its duration.
|
|
self._motion_events = [threading.Event() for _ in range(proto.NUM_CHANNELS)]
|
|
|
|
self.port_opened = Signal("port_opened") # ()
|
|
self.handshake_ok = Signal("handshake_ok") # proto_ver, fw_ver, n_ch
|
|
self.disconnected = Signal("disconnected") # reason ("" = user)
|
|
self.info_updated = Signal("info_updated") # ch, proto.Info
|
|
self.drv_status_updated = Signal("drv_status_updated") # ch, proto.DrvStatus
|
|
self.position_updated = Signal("position_updated") # ch, position
|
|
self.motion_done = Signal("motion_done") # ch, final_position
|
|
self.stopped = Signal("stopped") # ch, final_position
|
|
self.fault_occurred = Signal("fault_occurred") # ch, fault_mask
|
|
self.ack_received = Signal("ack_received") # req_cmd, status
|
|
self.frame_received = Signal("frame_received") # raw cmd, payload
|
|
|
|
# ── Connection ────────────────────────────────────────────────────────────
|
|
|
|
@property
|
|
def is_open(self) -> bool:
|
|
return self._is_open
|
|
|
|
def open(self, port: str, baud: int = 115200) -> None:
|
|
"""Open the serial port and start the reader; emits port_opened."""
|
|
if self._is_open:
|
|
self.close()
|
|
try:
|
|
self._ser = open_8n1(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 = _Reader(self._ser, self._on_frame, self._on_reader_finished)
|
|
self._reader.start()
|
|
self.port_opened.emit()
|
|
self.send_frame(proto.ping()) # handshake; polling starts on PONG
|
|
|
|
def close(self) -> None:
|
|
"""Close the 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.stop_polling()
|
|
self._is_open = False
|
|
|
|
# Release anyone blocked in wait_motion_done so a disconnect during a
|
|
# move raises there instead of hanging until the timeout.
|
|
for ev in self._motion_events:
|
|
ev.set()
|
|
|
|
reader, self._reader = self._reader, None
|
|
ser, self._ser = self._ser, None
|
|
|
|
if reader is not None:
|
|
reader.stop()
|
|
if threading.current_thread() is not reader:
|
|
reader.join(1.0)
|
|
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._motion_events[ch].clear()
|
|
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:
|
|
"""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 steps that rotate the stage by angle_deg."""
|
|
self.move(self.GR_AXIS_CH, self.steps_for_angle(angle_deg, microsteps),
|
|
velocity, accel)
|
|
|
|
def wait_motion_done(self, ch: int, timeout: float) -> bool:
|
|
"""Block until the channel reports MOTION_DONE. False on timeout.
|
|
|
|
Cleared by ``move()``, set by the MOTION_DONE event and by teardown,
|
|
so a disconnect mid-move unblocks immediately.
|
|
"""
|
|
return self._motion_events[ch].wait(timeout)
|
|
|
|
# ── Polling ───────────────────────────────────────────────────────────────
|
|
|
|
def start_polling(self):
|
|
if self._poll_thread is not None and self._poll_thread.is_alive():
|
|
return
|
|
self._poll_stop.clear()
|
|
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True,
|
|
name="T3RPoll")
|
|
self._poll_thread.start()
|
|
|
|
def stop_polling(self):
|
|
self._poll_stop.set()
|
|
|
|
def _poll_loop(self):
|
|
while not self._poll_stop.wait(self.POLL_INTERVAL_S):
|
|
if not self._is_open:
|
|
break
|
|
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_events[ev.ch].set()
|
|
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._motion_events[ev.ch].set()
|
|
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._motion_events[ev.ch].set()
|
|
self.fault_occurred.emit(ev.ch, ev.position)
|