1501 lines
63 KiB
Python
Executable File
1501 lines
63 KiB
Python
Executable File
#!/opt/srasenv/bin/python3
|
||
"""
|
||
sc3_aui_app.py — Scanengine-3 AUI
|
||
Loads sc3-aui-main.ui, sc3-aui-camera.ui, sc3-aui-scanprogress.ui via uic
|
||
and wires up T3R, BBD202, oscilloscope, and camera hardware workers.
|
||
"""
|
||
|
||
import json
|
||
import math
|
||
import queue
|
||
import struct
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
import threading
|
||
from threading import Thread
|
||
|
||
import serial
|
||
from PyQt6 import uic
|
||
from PyQt6.QtCore import QObject, QThread, QTimer, Qt, pyqtSignal, pyqtSlot
|
||
from PyQt6.QtGui import QImage, QPixmap
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QFileDialog, QLabel, QMainWindow, QMessageBox, QWidget,
|
||
)
|
||
|
||
ROOT = Path(__file__).parent
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
|
||
from hardware.tektronix_base import TektronixOscilloscopeBase
|
||
from hardware.uc480_camera import CameraStreamThread, UC480Camera
|
||
|
||
# ── Config defaults ──────────────────────────────────────────────────────────
|
||
|
||
_AUI_DEFAULTS_PATH = ROOT / "aui_defaults.json"
|
||
_AUI_DEFAULTS_FALLBACK = {
|
||
"t3r_port": "/dev/ttyUSB0",
|
||
"bbd_port": "/dev/ttyUSB1",
|
||
"oscope_ip": "192.168.0.1",
|
||
"laser_freq_hz": 2000,
|
||
"save_dir": str(ROOT / "scans"),
|
||
}
|
||
|
||
def _load_aui_defaults() -> dict:
|
||
if _AUI_DEFAULTS_PATH.exists():
|
||
try:
|
||
with open(_AUI_DEFAULTS_PATH) as f:
|
||
return {**_AUI_DEFAULTS_FALLBACK, **json.load(f)}
|
||
except Exception:
|
||
pass
|
||
# File absent or unreadable — write fresh copy and return fallback
|
||
_save_aui_defaults(_AUI_DEFAULTS_FALLBACK)
|
||
return dict(_AUI_DEFAULTS_FALLBACK)
|
||
|
||
def _save_aui_defaults(d: dict) -> None:
|
||
try:
|
||
with open(_AUI_DEFAULTS_PATH, "w") as f:
|
||
json.dump(d, f, indent=2)
|
||
except Exception as e:
|
||
print(f"[AUI] Could not save defaults: {e}")
|
||
|
||
_aui = _load_aui_defaults()
|
||
|
||
DEFAULT_T3R_PORT = _aui["t3r_port"]
|
||
DEFAULT_BBD_PORT = _aui["bbd_port"]
|
||
DEFAULT_OSCOPE_IP = _aui["oscope_ip"]
|
||
DEFAULT_LASER_FREQ_HZ = float(_aui["laser_freq_hz"])
|
||
DEFAULT_SAVE_DIR = _aui["save_dir"]
|
||
|
||
# ── Scan constants ───────────────────────────────────────────────────────────
|
||
|
||
SCAN_VELOCITY_MM_S = 100.0
|
||
SCAN_ACCEL_MM_S2 = 1500.0
|
||
LASER_FREQ_HZ = 20000.0 # laser pulse frequency during data acquisition
|
||
# Theoretical ramp distance: d = v² / (2a) = 100² / (2×1500) ≈ 3.33 mm
|
||
SCAN_RAMP_MM = SCAN_VELOCITY_MM_S**2 / (2.0 * SCAN_ACCEL_MM_S2)
|
||
# Extra buffer added to both ends of the ramp. The BBD202 controller begins
|
||
# decelerating slightly before the theoretical point to avoid overshoot, which
|
||
# causes TRIGOUT_MAXV to drop early and clip the last few data points.
|
||
# 1 mm at 100 mm/s and 2000 Hz corresponds to 20 extra trigger windows.
|
||
SCAN_RAMP_BUFFER_MM = 1.0
|
||
SCOPE_SAMPLE_RATE = 6.25e9 # 6.25 GS/s → 160 ps/sample
|
||
SCOPE_TRIG_LEVEL_V = 0.500
|
||
T3R_BAUD = 115200
|
||
|
||
# GR axis: CALIBRATE these two constants to your mechanical setup
|
||
GR_STEPS_PER_DEGREE = 100 # TMC2130 microsteps per degree of GR rotation
|
||
GR_MOVE_SPEED_HZ = 2000 # steps/sec for inter-angle GR moves
|
||
GR_JOG_STEPS = 200 # steps per manual jog press
|
||
T1T2T3_JOG_STEPS = 500 # steps per manual jog press for T1/T2/T3
|
||
|
||
_T3R_AXIS_NAMES = {1: "T1", 2: "T2", 3: "T3", 4: "GR"}
|
||
|
||
BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202
|
||
BBD_JOG_SPEED_MM_S = 10.0
|
||
BBD_JOG_ACCEL_MM_S2 = 50.0
|
||
|
||
# ── Binary blob format ───────────────────────────────────────────────────────
|
||
# Full spec: scan_format.md
|
||
BLOB_MAGIC = b"SRAS"
|
||
BLOB_VERSION = 4
|
||
SCAN_CHANNELS = [1, 3, 4] # oscilloscope channels recorded, in order
|
||
# ">4s B H H f f f f I I d B B"
|
||
# magic ver n_angles n_rows xs xd vel freq nf spf sr bps n_channels
|
||
BLOB_HDR_FMT = ">4sBHHffffIIdBB"
|
||
|
||
|
||
def _open_scan_file(path: Path, n_angles: int, n_rows: int,
|
||
x_start: float, x_delta: float,
|
||
velocity: float, laser_freq: float,
|
||
n_frames: int, samples_per_frame: int,
|
||
sample_rate: float,
|
||
angles: list[float], y_positions: list[float],
|
||
preambles: list[str],
|
||
background_waveform: bytes):
|
||
"""Create a new SRAS file and write the global header + angle/row tables.
|
||
|
||
Returns an open binary file object positioned at the start of the data
|
||
block. The caller must close it (use in a try/finally block).
|
||
|
||
Data is written by appending waveforms in angle-major, row-minor,
|
||
channel-inner order: for each angle, for each row, for each channel in
|
||
SCAN_CHANNELS order, all n_frames waveforms are written sequentially.
|
||
|
||
preambles: one WFMOutpre string per channel (same order as SCAN_CHANNELS),
|
||
written as length-prefixed UTF-8 blocks after the row table.
|
||
|
||
background_waveform: raw int8 bytes of a 64-sample averaged CH1 waveform
|
||
captured with the Helios laser enabled and Genesis laser
|
||
disabled, written as uint32 length prefix followed by
|
||
the data.
|
||
"""
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
f = open(path, "wb")
|
||
header = struct.pack(
|
||
BLOB_HDR_FMT,
|
||
BLOB_MAGIC, BLOB_VERSION,
|
||
n_angles, n_rows,
|
||
x_start, x_delta,
|
||
velocity, laser_freq,
|
||
n_frames, samples_per_frame,
|
||
sample_rate,
|
||
1, # bytes_per_sample: int8 from scope default
|
||
len(SCAN_CHANNELS), # n_channels
|
||
)
|
||
f.write(header)
|
||
f.write(struct.pack(f">{n_angles}f", *angles))
|
||
f.write(struct.pack(f">{n_rows}f", *y_positions))
|
||
for p in preambles:
|
||
enc = p.encode("utf-8")
|
||
f.write(struct.pack(">H", len(enc)))
|
||
f.write(enc)
|
||
# v4: background waveform block — CH1 64-sample average (Helios ON, Genesis OFF)
|
||
f.write(struct.pack(">I", len(background_waveform)))
|
||
f.write(background_waveform)
|
||
return f
|
||
|
||
|
||
# ── T3R worker ───────────────────────────────────────────────────────────────
|
||
|
||
class _T3RReader(QThread):
|
||
"""Background thread that reads newline-terminated JSON from T3R serial."""
|
||
data_received = pyqtSignal(str)
|
||
|
||
def __init__(self, ser: serial.Serial):
|
||
super().__init__()
|
||
self._ser = ser
|
||
self._running = True
|
||
|
||
def run(self):
|
||
while self._running:
|
||
try:
|
||
if self._ser.is_open and self._ser.in_waiting:
|
||
line = self._ser.readline().decode("utf-8", errors="replace").strip()
|
||
if line:
|
||
self.data_received.emit(line)
|
||
else:
|
||
self.msleep(10)
|
||
except Exception:
|
||
self.msleep(50)
|
||
|
||
def stop(self):
|
||
self._running = False
|
||
self.wait(500)
|
||
|
||
|
||
class T3RWorker(QObject):
|
||
"""Manages T3R stepper controller over JSON serial in a worker thread."""
|
||
connected = pyqtSignal()
|
||
disconnected = pyqtSignal()
|
||
connection_failed = pyqtSignal(str)
|
||
response_received = pyqtSignal(dict)
|
||
error_occurred = pyqtSignal(str)
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._ser: serial.Serial | None = None
|
||
self._reader: _T3RReader | None = None
|
||
self._cmd_q: queue.Queue = queue.Queue()
|
||
self._resp_q: queue.Queue = queue.Queue()
|
||
self._running = False
|
||
self.is_connected = False
|
||
|
||
@pyqtSlot()
|
||
def run(self):
|
||
self._running = True
|
||
while self._running:
|
||
try:
|
||
cmd = self._cmd_q.get(timeout=0.05)
|
||
self._dispatch(cmd)
|
||
except queue.Empty:
|
||
pass
|
||
if self._ser and self._ser.is_open:
|
||
self._ser.close()
|
||
|
||
def _dispatch(self, cmd: dict):
|
||
t = cmd["type"]
|
||
if t == "connect":
|
||
self._do_connect(cmd["port"], cmd["baud"])
|
||
elif t == "disconnect":
|
||
self._do_disconnect()
|
||
elif t == "send":
|
||
self._send_json(cmd["payload"])
|
||
elif t == "stop":
|
||
self._running = False
|
||
|
||
def _do_connect(self, port: str, baud: int):
|
||
try:
|
||
self._ser = serial.Serial(port, baud, timeout=0.1)
|
||
self._reader = _T3RReader(self._ser)
|
||
self._reader.data_received.connect(self._on_line)
|
||
self._reader.start()
|
||
self.is_connected = True
|
||
self.connected.emit()
|
||
except Exception as e:
|
||
self.connection_failed.emit(str(e))
|
||
|
||
def _do_disconnect(self):
|
||
if self._reader:
|
||
self._reader.stop()
|
||
self._reader = None
|
||
if self._ser and self._ser.is_open:
|
||
self._ser.close()
|
||
self._ser = None
|
||
self.is_connected = False
|
||
self.disconnected.emit()
|
||
|
||
def _send_json(self, payload: dict):
|
||
if self._ser and self._ser.is_open:
|
||
raw = json.dumps(payload, separators=(",", ":")) + "\n"
|
||
self._ser.write(raw.encode())
|
||
|
||
def _on_line(self, line: str):
|
||
try:
|
||
obj = json.loads(line)
|
||
self.response_received.emit(obj)
|
||
self._resp_q.put(obj)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
# ── Queue API (from UI thread) ────────────────────────────────────────────
|
||
|
||
def queue_connect(self, port: str, baud: int = T3R_BAUD):
|
||
self._cmd_q.put({"type": "connect", "port": port, "baud": baud})
|
||
|
||
def queue_disconnect(self):
|
||
self._cmd_q.put({"type": "disconnect"})
|
||
|
||
def queue_send(self, payload: dict):
|
||
self._cmd_q.put({"type": "send", "payload": payload})
|
||
|
||
def queue_enable(self, axis: int, on: bool):
|
||
self.queue_send({"verb": "enable", "parameter": str(axis),
|
||
"extra": "true" if on else "false"})
|
||
|
||
def queue_set_current(self, axis: int, ma: int):
|
||
self.queue_send({"verb": "current", "parameter": str(axis),
|
||
"extra": str(ma)})
|
||
|
||
def queue_move(self, axis: int, steps: int, speed: int):
|
||
self.queue_send({"verb": "move", "parameter": str(axis),
|
||
"extra": f"{steps},{speed}"})
|
||
|
||
# ── Sync API (from scan worker thread) ───────────────────────────────────
|
||
|
||
def send_sync(self, payload: dict, timeout: float = 30.0) -> dict:
|
||
"""Send a command directly and block until a response arrives.
|
||
Must be called from the scan thread, not the UI thread."""
|
||
while not self._resp_q.empty():
|
||
try:
|
||
self._resp_q.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
if self._ser and self._ser.is_open:
|
||
raw = json.dumps(payload, separators=(",", ":")) + "\n"
|
||
self._ser.write(raw.encode())
|
||
return self._resp_q.get(timeout=timeout)
|
||
|
||
def stop_worker(self):
|
||
self._cmd_q.put({"type": "stop"})
|
||
|
||
|
||
# ── BBD202 worker ─────────────────────────────────────────────────────────────
|
||
|
||
class BBD202Worker(QObject):
|
||
"""Manages ThorlabsServoDriver (MLS203-1) in a worker thread."""
|
||
connected = pyqtSignal()
|
||
disconnected = pyqtSignal()
|
||
connection_failed = pyqtSignal(str)
|
||
position_updated = pyqtSignal(float, float) # x_mm, y_mm
|
||
homed_status = pyqtSignal(bool, bool) # x_homed, y_homed
|
||
error_occurred = pyqtSignal(str)
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.controller: ThorlabsServoDriver | None = None
|
||
self._cmd_q: queue.Queue = queue.Queue()
|
||
self._running = False
|
||
self.is_connected = False
|
||
self.scanning_active = False # pause polling during scan
|
||
self._jog_step = BBD_DEFAULT_JOG_MM
|
||
self._last_x: float | None = None
|
||
self._last_y: float | None = None
|
||
self._last_xh: bool | None = None
|
||
self._last_yh: bool | None = None
|
||
self._last_poll_t = 0.0
|
||
|
||
@pyqtSlot()
|
||
def run(self):
|
||
self._running = True
|
||
while self._running:
|
||
try:
|
||
cmd = self._cmd_q.get(timeout=0.05)
|
||
self._dispatch(cmd)
|
||
except queue.Empty:
|
||
pass
|
||
if self.is_connected and self.controller and not self.scanning_active:
|
||
self._poll()
|
||
if self.controller:
|
||
try:
|
||
self.controller.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
def _dispatch(self, cmd: dict):
|
||
t = cmd["type"]
|
||
if t == "connect":
|
||
self._do_connect(cmd["port"])
|
||
elif t == "disconnect":
|
||
self._do_disconnect()
|
||
elif t == "jog":
|
||
self._do_jog(cmd["axis"], cmd["direction"])
|
||
elif t == "home_all":
|
||
self._do_home_all()
|
||
elif t == "enable_all":
|
||
self._do_enable_all()
|
||
elif t == "stop":
|
||
self._running = False
|
||
|
||
def _do_connect(self, port: str):
|
||
try:
|
||
self.controller = ThorlabsServoDriver()
|
||
self.controller.serial_port = port
|
||
self.controller.connect()
|
||
self.controller.enable_axis(AXIS_X)
|
||
self.controller.enable_axis(AXIS_Y)
|
||
self.controller.start_polling(interval=0.2)
|
||
time.sleep(0.3)
|
||
self.controller.set_velocity_params(AXIS_X, max_velocity=BBD_JOG_SPEED_MM_S,
|
||
acceleration=BBD_JOG_ACCEL_MM_S2)
|
||
self.controller.set_velocity_params(AXIS_Y, max_velocity=BBD_JOG_SPEED_MM_S,
|
||
acceleration=BBD_JOG_ACCEL_MM_S2)
|
||
self.is_connected = True
|
||
self.connected.emit()
|
||
except Exception as e:
|
||
self.connection_failed.emit(str(e))
|
||
|
||
def _do_disconnect(self):
|
||
if self.controller:
|
||
try:
|
||
self.controller.disconnect()
|
||
except Exception:
|
||
pass
|
||
self.controller = None
|
||
self.is_connected = False
|
||
self.disconnected.emit()
|
||
|
||
def _do_jog(self, axis: str, direction: int):
|
||
if not self.controller:
|
||
return
|
||
dest = AXIS_X if axis == "x" else AXIS_Y
|
||
try:
|
||
self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0)
|
||
except TimeoutError:
|
||
pass
|
||
except Exception as e:
|
||
self.error_occurred.emit(str(e))
|
||
|
||
def _do_home_all(self):
|
||
if not self.controller:
|
||
return
|
||
def _home_task():
|
||
for dest, name in [(AXIS_X, "X"), (AXIS_Y, "Y")]:
|
||
try:
|
||
self.controller.home_axis(dest, timeout=120.0)
|
||
except Exception as e:
|
||
self.error_occurred.emit(f"Home {name} failed: {e}")
|
||
Thread(target=_home_task, daemon=True).start()
|
||
|
||
def _do_enable_all(self):
|
||
if not self.controller:
|
||
return
|
||
self.controller.toggle_enabled_state(AXIS_X)
|
||
self.controller.toggle_enabled_state(AXIS_Y)
|
||
|
||
def _poll(self):
|
||
now = time.time()
|
||
if now - self._last_poll_t < 0.2:
|
||
return
|
||
self._last_poll_t = now
|
||
try:
|
||
x = self.controller.positions[0]
|
||
y = self.controller.positions[1]
|
||
if x != self._last_x or y != self._last_y:
|
||
self._last_x = x
|
||
self._last_y = y
|
||
self.position_updated.emit(x, y)
|
||
xh = self.controller.am_homed[0]
|
||
yh = self.controller.am_homed[1]
|
||
if xh != self._last_xh or yh != self._last_yh:
|
||
self._last_xh = xh
|
||
self._last_yh = yh
|
||
self.homed_status.emit(xh, yh)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Queue API ─────────────────────────────────────────────────────────────
|
||
|
||
def queue_connect(self, port: str):
|
||
self._cmd_q.put({"type": "connect", "port": port})
|
||
|
||
def queue_disconnect(self):
|
||
self._cmd_q.put({"type": "disconnect"})
|
||
|
||
def queue_jog(self, axis: str, direction: int):
|
||
self._cmd_q.put({"type": "jog", "axis": axis, "direction": direction})
|
||
|
||
def queue_home_all(self):
|
||
self._cmd_q.put({"type": "home_all"})
|
||
|
||
def queue_enable_all(self):
|
||
self._cmd_q.put({"type": "enable_all"})
|
||
|
||
def stop_worker(self):
|
||
self._cmd_q.put({"type": "stop"})
|
||
|
||
|
||
# ── Oscilloscope worker ───────────────────────────────────────────────────────
|
||
|
||
class OscopeWorker(QObject):
|
||
"""Manages TektronixOscilloscopeBase in a worker thread."""
|
||
connected = pyqtSignal()
|
||
disconnected = pyqtSignal()
|
||
connection_failed = pyqtSignal(str)
|
||
error_occurred = pyqtSignal(str)
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.scope: TektronixOscilloscopeBase | None = None
|
||
self._cmd_q: queue.Queue = queue.Queue()
|
||
self._running = False
|
||
self.is_connected = False
|
||
|
||
@pyqtSlot()
|
||
def run(self):
|
||
self._running = True
|
||
while self._running:
|
||
try:
|
||
cmd = self._cmd_q.get(timeout=0.1)
|
||
t = cmd["type"]
|
||
if t == "connect":
|
||
self._do_connect(cmd["ip"])
|
||
elif t == "disconnect":
|
||
self._do_disconnect()
|
||
elif t == "stop":
|
||
self._running = False
|
||
except queue.Empty:
|
||
pass
|
||
if self.scope:
|
||
try:
|
||
self.scope.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
def _do_connect(self, ip: str):
|
||
try:
|
||
self.scope = TektronixOscilloscopeBase(resource_name=ip, port=4000, timeout=10.0)
|
||
self.scope.connect()
|
||
self._configure_channels()
|
||
self.is_connected = True
|
||
self.connected.emit()
|
||
except Exception as e:
|
||
self.connection_failed.emit(str(e))
|
||
|
||
def _configure_channels(self):
|
||
"""Apply standard SRAS channel configuration after connecting."""
|
||
s = self.scope
|
||
|
||
# Turn on all four channels
|
||
for ch in (1, 2, 3, 4):
|
||
s.write(f"SELect:CH{ch} ON")
|
||
|
||
# ── CH1 — RF Acoustic Packet ──────────────────────────────────────────
|
||
s.set_channel_label_name(1, "RF Acoustic Packet")
|
||
s.set_channel_scale(1, 0.05) # 100 mV/div
|
||
s.set_channel_position(1, 0.0) # 0 divs
|
||
s.set_channel_termination(1, 50) # 50 Ohm
|
||
s.set_channel_coupling(1, "DC")
|
||
s.set_channel_bandwidth(1, 250E6) # 250Mhz Low-Pass
|
||
|
||
# ── CH2 — Trigger Signal ──────────────────────────────────────────────
|
||
s.set_channel_label_name(2, "Trigger Signal")
|
||
s.set_channel_scale(2, 0.5) # 500 mV/div
|
||
s.set_channel_position(2, -2.72) # -2.72 divs
|
||
s.set_channel_termination(2, 1000000) # 50 Ohm
|
||
s.set_channel_coupling(2, "DC")
|
||
s.set_channel_bandwidth(2, 20E6) # 20 MHz
|
||
|
||
# ── CH3 — Max Velocity Gate ───────────────────────────────────────────
|
||
s.set_channel_label_name(3, "Max Vel Gate")
|
||
s.set_channel_scale(3, 1.0) # 1 V/div
|
||
s.set_channel_position(3, -2.72) # -2.72 divs
|
||
s.set_channel_termination(3, 1000000) # 1 MOhm
|
||
s.set_channel_coupling(3, "DC")
|
||
s.set_channel_bandwidth(3, 20E6) # 20 MHz
|
||
|
||
# ── CH4 — Bias B ──────────────────────────────────────────────────────
|
||
s.set_channel_label_name(4, "Bias - B")
|
||
s.set_channel_scale(4, 0.1) # 50 mV/div
|
||
s.set_channel_position(4, -2.72) # -2.72 divs
|
||
s.set_channel_termination(4, 1000000) # 1 Mohm
|
||
s.set_channel_coupling(4, "DC")
|
||
s.set_channel_bandwidth(4, 20E6) # 20 MHz
|
||
|
||
def _do_disconnect(self):
|
||
if self.scope:
|
||
try:
|
||
self.scope.disconnect()
|
||
except Exception:
|
||
pass
|
||
self.scope = None
|
||
self.is_connected = False
|
||
self.disconnected.emit()
|
||
|
||
def queue_connect(self, ip: str):
|
||
self._cmd_q.put({"type": "connect", "ip": ip})
|
||
|
||
def queue_disconnect(self):
|
||
self._cmd_q.put({"type": "disconnect"})
|
||
|
||
def stop_worker(self):
|
||
self._cmd_q.put({"type": "stop"})
|
||
|
||
|
||
# ── Camera window popup ───────────────────────────────────────────────────────
|
||
|
||
class CameraWindow(QWidget):
|
||
"""Camera display popup — auto-connects on show, auto-disconnects on close."""
|
||
|
||
def __init__(self, parent: QWidget | None = None):
|
||
super().__init__(parent, Qt.WindowType.Window)
|
||
uic.loadUi(ROOT / "sc3-aui-camera.ui", self)
|
||
self.setWindowTitle("uC480 Camera")
|
||
|
||
self._camera: UC480Camera | None = None
|
||
self._stream: CameraStreamThread | None = None
|
||
|
||
# Overlay a QLabel for frame rendering on top of the native display widget
|
||
self._frame_label = QLabel(self.uc480_display_area)
|
||
self._frame_label.setGeometry(
|
||
0, 0,
|
||
self.uc480_display_area.width(),
|
||
self.uc480_display_area.height(),
|
||
)
|
||
self._frame_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._frame_label.setStyleSheet("background: black;")
|
||
|
||
self.uc480_exposure_slider.setRange(1, 500) # 0.1 ms … 50 ms (×0.1)
|
||
self.uc480_gain_slider.setRange(0, 100)
|
||
|
||
self.uc480_exposure_slider.valueChanged.connect(self._set_exposure)
|
||
self.uc480_gain_slider.valueChanged.connect(self._set_gain)
|
||
self.uc480_close_window_btn.clicked.connect(self.close)
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
self._start_camera()
|
||
|
||
def closeEvent(self, event):
|
||
self._stop_camera()
|
||
super().closeEvent(event)
|
||
|
||
def _start_camera(self):
|
||
if self._stream is not None:
|
||
return
|
||
try:
|
||
self._camera = UC480Camera()
|
||
self._camera.initialize()
|
||
exp_ms = self._camera.get_exposure()
|
||
self.uc480_exposure_slider.blockSignals(True)
|
||
self.uc480_exposure_slider.setValue(max(1, round(exp_ms * 10)))
|
||
self.uc480_exposure_slider.blockSignals(False)
|
||
self._stream = CameraStreamThread(self._camera)
|
||
self._stream.frame_ready.connect(self._on_frame)
|
||
self._stream.start()
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Camera Error", f"Could not open camera:\n{e}")
|
||
|
||
def _stop_camera(self):
|
||
if self._stream:
|
||
self._stream.stop()
|
||
self._stream = None
|
||
if self._camera:
|
||
try:
|
||
self._camera.cleanup()
|
||
except Exception:
|
||
pass
|
||
self._camera = None
|
||
|
||
def _on_frame(self, image: QImage):
|
||
px = QPixmap.fromImage(image).scaled(
|
||
self._frame_label.width(),
|
||
self._frame_label.height(),
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.FastTransformation,
|
||
)
|
||
self._frame_label.setPixmap(px)
|
||
|
||
def _set_exposure(self, val: int):
|
||
if self._camera:
|
||
try:
|
||
self._camera.set_exposure(val * 0.1)
|
||
except Exception:
|
||
pass
|
||
|
||
def _set_gain(self, val: int):
|
||
if self._camera:
|
||
try:
|
||
self._camera.set_gain(val)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ── Scan progress popup ───────────────────────────────────────────────────────
|
||
|
||
class ScanProgressWindow(QWidget):
|
||
abort_requested = pyqtSignal()
|
||
|
||
def __init__(self, parent: QWidget | None = None):
|
||
super().__init__(parent, Qt.WindowType.Window)
|
||
uic.loadUi(ROOT / "sc3-aui-scanprogress.ui", self)
|
||
self.setWindowTitle("Scan Progress")
|
||
self.abort_btn.clicked.connect(self.abort_requested.emit)
|
||
|
||
def update_progress(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
|
||
self.current_scan_current_row_indicator.setText(f"Row {row} of {n_rows}")
|
||
pct_row = round(row / n_rows * 100) if n_rows > 0 else 0
|
||
self.current_scan_progbar.setValue(pct_row)
|
||
self.overall_scan_current_angle_indicator.setText(f"Angle {angle_idx} of {n_angles}")
|
||
pct_ang = round(angle_idx / n_angles * 100) if n_angles > 0 else 0
|
||
self.overall_scan_progbar.setValue(pct_ang)
|
||
|
||
|
||
# ── Scan worker ───────────────────────────────────────────────────────────────
|
||
|
||
class ScanWorker(QObject):
|
||
"""Runs the full SRAS scan sequence in a background thread.
|
||
|
||
Accesses hardware drivers directly (not through worker queues) so it can
|
||
make blocking calls. BBD202Worker.scanning_active is set to True for the
|
||
duration to suppress position polling in the BBD worker thread.
|
||
"""
|
||
started = pyqtSignal()
|
||
completed = pyqtSignal()
|
||
failed = pyqtSignal(str)
|
||
row_done = pyqtSignal(int, int, int, int) # row, n_rows, angle_idx, n_angles
|
||
status_msg = pyqtSignal(str)
|
||
user_prompt = pyqtSignal(str, str) # title, message — ask user to acknowledge
|
||
|
||
def __init__(self, bbd: BBD202Worker, t3r: T3RWorker,
|
||
oscope: OscopeWorker, params: dict):
|
||
super().__init__()
|
||
self._bbd = bbd
|
||
self._t3r = t3r
|
||
self._oscope = oscope
|
||
self._params = params
|
||
self._abort = False
|
||
self._prompt_event = threading.Event()
|
||
|
||
def abort(self):
|
||
self._abort = True
|
||
|
||
def acknowledge_prompt(self):
|
||
"""Called from UI thread when user clicks OK on a prompt dialog."""
|
||
self._prompt_event.set()
|
||
|
||
def _request_user_prompt(self, title: str, message: str):
|
||
"""Emit a prompt signal and block until the UI thread acknowledges it."""
|
||
self._prompt_event.clear()
|
||
self.user_prompt.emit(title, message)
|
||
self._prompt_event.wait()
|
||
|
||
@pyqtSlot()
|
||
def run(self):
|
||
try:
|
||
self._run_scan()
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.failed.emit(str(e))
|
||
|
||
def _run_scan(self):
|
||
p = self._params
|
||
x_start = p["x_start"]
|
||
y_start = p["y_start"]
|
||
x_delta = p["x_delta"]
|
||
y_delta = p["y_delta"]
|
||
n_angles = max(1, p["num_angles"])
|
||
row_spacing = p["row_spacing"]
|
||
prefix = p["prefix"]
|
||
save_dir = Path(p["save_dir"])
|
||
|
||
# ── Compute scan geometry ─────────────────────────────────────────────
|
||
if n_angles > 1:
|
||
angles = [i * 180.0 / (n_angles - 1) for i in range(n_angles)]
|
||
else:
|
||
angles = [0.0]
|
||
|
||
n_rows = max(1, round(y_delta / row_spacing) + 1) if y_delta > 0 else 1
|
||
y_positions = [y_start + i * row_spacing for i in range(n_rows)]
|
||
points_per_row = max(1, round(x_delta * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S))
|
||
|
||
# ── Validate scan geometry against stage travel limits ─────────────────
|
||
# X axis: 0–110 mm (bbd20x.py). The actual move starts one ramp-length
|
||
# + buffer before x_start and ends one ramp-length + buffer after
|
||
# x_start + x_delta.
|
||
_x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
||
x_move_start = x_start - _x_ramp_total
|
||
x_move_end = x_start + x_delta + _x_ramp_total
|
||
if x_move_start < 0.0:
|
||
raise ValueError(
|
||
f"Scan pre-ramp start ({x_move_start:.3f} mm) is below the X axis "
|
||
f"minimum (0 mm). Increase x_start by at least "
|
||
f"{-x_move_start:.3f} mm "
|
||
f"(SCAN_RAMP_MM={SCAN_RAMP_MM:.3f} + SCAN_RAMP_BUFFER_MM={SCAN_RAMP_BUFFER_MM:.3f})."
|
||
)
|
||
if x_move_end > 110.0:
|
||
raise ValueError(
|
||
f"Scan run-off end ({x_move_end:.3f} mm) exceeds the X axis "
|
||
f"maximum (110 mm). Reduce x_start + x_delta by at least "
|
||
f"{x_move_end - 110.0:.3f} mm."
|
||
)
|
||
# Y axis: 0–75 mm
|
||
y_min = min(y_positions)
|
||
y_max = max(y_positions)
|
||
if y_min < 0.0:
|
||
raise ValueError(
|
||
f"Scan Y range starts at {y_min:.3f} mm, below the Y axis minimum (0 mm)."
|
||
)
|
||
if y_max > 75.0:
|
||
raise ValueError(
|
||
f"Scan Y range ends at {y_max:.3f} mm, exceeds the Y axis maximum (75 mm)."
|
||
)
|
||
|
||
self.status_msg.emit(
|
||
f"Scan geometry: {n_angles} angle(s) × {n_rows} row(s) × "
|
||
f"{points_per_row} pts/row | save → {save_dir}"
|
||
)
|
||
self.started.emit()
|
||
|
||
# ── Validate hardware ─────────────────────────────────────────────────
|
||
ctrl = self._bbd.controller
|
||
scope = self._oscope.scope
|
||
if ctrl is None:
|
||
raise RuntimeError("BBD202 not connected")
|
||
if scope is None:
|
||
raise RuntimeError("Oscilloscope not connected")
|
||
|
||
# ── Prepare stage ─────────────────────────────────────────────────────
|
||
self.status_msg.emit("Enabling stage axes …")
|
||
if not ctrl.am_enabled[0]:
|
||
ctrl.enable_axis(AXIS_X)
|
||
if not ctrl.am_enabled[1]:
|
||
ctrl.enable_axis(AXIS_Y)
|
||
time.sleep(0.2)
|
||
|
||
if not ctrl.am_homed[0] or not ctrl.am_homed[1]:
|
||
self.status_msg.emit("Homing stage (may take up to 2 min) …")
|
||
if not ctrl.am_homed[0]:
|
||
ctrl.home_axis(AXIS_X, timeout=120.0)
|
||
if not ctrl.am_homed[1]:
|
||
ctrl.home_axis(AXIS_Y, timeout=120.0)
|
||
|
||
self.status_msg.emit("Setting scan velocity …")
|
||
ctrl.set_velocity_params(AXIS_X, max_velocity=SCAN_VELOCITY_MM_S,
|
||
acceleration=SCAN_ACCEL_MM_S2)
|
||
ctrl.set_velocity_params(AXIS_Y, max_velocity=SCAN_VELOCITY_MM_S,
|
||
acceleration=SCAN_ACCEL_MM_S2)
|
||
|
||
# X trigger: logic-high output when stage is at maximum velocity
|
||
ctrl.set_trigger_trigout_maxv(AXIS_X)
|
||
|
||
# ── Configure oscilloscope ────────────────────────────────────────────
|
||
self.status_msg.emit("Configuring oscilloscope …")
|
||
# Edge trigger: rising edge of CH2 (laser pulse) at 1.0 V.
|
||
scope.write("TRIGger:A:TYPe EDGE")
|
||
scope.set_trigger_source(2)
|
||
scope.set_trigger_slope("RISE")
|
||
scope.set_trigger_level(2, SCOPE_TRIG_LEVEL_V)
|
||
scope.set_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep)
|
||
scope.set_acquire_mode("SAMPLE")
|
||
scope.set_fastframe_state(False) # start non-FF for background capture
|
||
scope.set_sample_rate(SCOPE_SAMPLE_RATE)
|
||
scope.write("HORizontal:POSition 30") # 10 % trigger offset
|
||
time.sleep(0.3)
|
||
samples_per_frame = scope.get_record_length()
|
||
|
||
# ── Snapshot WFMOutpre for each channel (captures YMULT/YOFF/YZERO) ─────
|
||
preambles = []
|
||
for ch in SCAN_CHANNELS:
|
||
scope.set_data_source(ch)
|
||
preambles.append(scope.query_wfmoutpre())
|
||
|
||
# ── Background subtraction capture ────────────────────────────────────
|
||
# Prompt user to ensure Helios is ON and Genesis is OFF.
|
||
self._request_user_prompt(
|
||
"Background Capture",
|
||
"Please ensure the Helios laser is ON and the Genesis laser is OFF,\n"
|
||
"then click OK to capture the background waveform."
|
||
)
|
||
if self._abort:
|
||
self.failed.emit("Scan aborted by user.")
|
||
return
|
||
|
||
# Capture a single averaged CH1 frame (1024 waveforms averaged).
|
||
self.status_msg.emit("Capturing background waveform (1024-average) …")
|
||
scope.set_acquire_mode("AVERAGE")
|
||
scope.write("ACQuire:NUMAVg 1024")
|
||
scope.write("ACQuire:STOPAfter SEQuence") # auto-stop after all 1024 averages
|
||
scope.set_data_source(1)
|
||
scope.write("ACQuire:STATE RUN")
|
||
# Poll until the scope finishes all 1024 averages and auto-stops.
|
||
# Timeout: 1024 averages at 2 kHz (worst case) = ~0.5 s; allow 60 s.
|
||
_bg_deadline = time.time() + 60.0
|
||
while time.time() < _bg_deadline:
|
||
if self._abort:
|
||
break
|
||
if scope.query("ACQuire:STATE?").strip() == "0":
|
||
break
|
||
time.sleep(0.25)
|
||
else:
|
||
scope.write("ACQuire:STATE STOP")
|
||
self.status_msg.emit("Warning: background average timed out; stopping early.")
|
||
time.sleep(0.1)
|
||
background_waveform = scope.transfer_curve()
|
||
|
||
# Prompt user to turn Genesis back on before the actual scan.
|
||
self._request_user_prompt(
|
||
"Resume Scan",
|
||
"Background captured successfully.\n\n"
|
||
"Please ensure the Genesis laser is back ON,\n"
|
||
"then click OK to begin scanning."
|
||
)
|
||
if self._abort:
|
||
self.failed.emit("Scan aborted by user.")
|
||
return
|
||
|
||
# Restore SAMPLE mode and FastFrame for the actual scan.
|
||
scope.write("ACQuire:STOPAfter RUNSTop")
|
||
scope.set_acquire_mode("SAMPLE")
|
||
scope.set_fastframe_state(True)
|
||
scope.set_fastframe_count(points_per_row)
|
||
|
||
# Restore logic-AND trigger (CH2 HIGH AND CH3 HIGH) for the scan loop:
|
||
# CH3 is the BBD202 TRIGOUT_MAXV gate, so frames only accumulate while
|
||
# the stage is at full scan velocity.
|
||
scope.write("TRIGger:A:TYPe LOGIc")
|
||
scope.write("TRIGger:A:LOGIc:FUNCtion AND")
|
||
scope.set_trigger_level(2, SCOPE_TRIG_LEVEL_V)
|
||
scope.set_trigger_level(3, SCOPE_TRIG_LEVEL_V)
|
||
scope.write("TRIGger:A:LOGICPattern:CH2 HIGH")
|
||
scope.write("TRIGger:A:LOGICPattern:CH3 HIGH")
|
||
time.sleep(0.2)
|
||
|
||
# ── Open output file (entire scan in one .sras) ───────────────────────
|
||
fname = save_dir / f"{prefix}.sras"
|
||
scan_file = _open_scan_file(
|
||
fname, n_angles, n_rows,
|
||
x_start, x_delta, SCAN_VELOCITY_MM_S, LASER_FREQ_HZ,
|
||
points_per_row, samples_per_frame, SCOPE_SAMPLE_RATE,
|
||
angles, y_positions,
|
||
preambles, background_waveform,
|
||
)
|
||
|
||
# ── Scan loop ─────────────────────────────────────────────────────────
|
||
self._bbd.scanning_active = True
|
||
prev_gr_steps = 0
|
||
try:
|
||
for ai, angle in enumerate(angles):
|
||
if self._abort:
|
||
break
|
||
|
||
# ── Rotate GR ─────────────────────────────────────────────────
|
||
if self._t3r.is_connected:
|
||
target_gr = round(angle * GR_STEPS_PER_DEGREE)
|
||
delta_gr = target_gr - prev_gr_steps
|
||
if delta_gr != 0:
|
||
est_secs = abs(delta_gr) / GR_MOVE_SPEED_HZ
|
||
self.status_msg.emit(
|
||
f"Rotating GR to {angle:.1f}° (≈{est_secs:.1f} s) …"
|
||
)
|
||
try:
|
||
self._t3r.send_sync(
|
||
{"verb": "move", "parameter": "4",
|
||
"extra": f"{delta_gr},{GR_MOVE_SPEED_HZ}"},
|
||
timeout=max(5.0, est_secs + 3.0),
|
||
)
|
||
except Exception as e:
|
||
self.status_msg.emit(f"GR move warning: {e}")
|
||
time.sleep(est_secs + 0.5)
|
||
prev_gr_steps = target_gr
|
||
|
||
# ── Row loop ──────────────────────────────────────────────────
|
||
for ri, y_pos in enumerate(y_positions):
|
||
if self._abort:
|
||
break
|
||
|
||
self.status_msg.emit(
|
||
f"Angle {ai+1}/{n_angles} Row {ri+1}/{n_rows} "
|
||
f"(Y={y_pos:.3f} mm)"
|
||
)
|
||
|
||
# Position stage one ramp-length + buffer before the data
|
||
# window so the stage is at full velocity before x_start.
|
||
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
|
||
ctrl.move_axis_absolute(AXIS_X, x_start - _x_ramp_total, timeout=30.0)
|
||
|
||
# Arm oscilloscope — trigger is gated with TRIGOUT_MAXV so
|
||
# frames only accumulate once the stage reaches full velocity
|
||
scope.write("ACQuire:STATE RUN")
|
||
time.sleep(0.05)
|
||
|
||
# Execute scan move: data window + ramp + buffer run-off so
|
||
# the stage does not begin decelerating before the last point.
|
||
x_end = x_start + x_delta + _x_ramp_total
|
||
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||
|
||
# Brief settle: wait for trailing frames then stop acquisition
|
||
time.sleep(0.2)
|
||
scope.write("ACQuire:STATE STOP")
|
||
|
||
# Stream all channels from oscilloscope and append to file.
|
||
# CH3 is the max-vel gate signal — no useful waveform data,
|
||
# so write zeroed frames to keep the file format intact.
|
||
for ch in SCAN_CHANNELS:
|
||
if ch == 3:
|
||
self.status_msg.emit("Writing zeroed CH3 frames …")
|
||
zero_frame = bytes(samples_per_frame)
|
||
n_frames = int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
||
for _ in range(n_frames):
|
||
scan_file.write(zero_frame)
|
||
else:
|
||
self.status_msg.emit(f"Fetching CH{ch} data …")
|
||
scope.set_data_source(ch)
|
||
waveforms: list[bytes] = scope.transfer_fastframe(parse=False)
|
||
for w in waveforms:
|
||
scan_file.write(w)
|
||
|
||
self.row_done.emit(ri + 1, n_rows, ai + 1, n_angles)
|
||
|
||
finally:
|
||
scan_file.close()
|
||
self._bbd.scanning_active = False
|
||
|
||
if self._abort:
|
||
self.failed.emit("Scan aborted by user.")
|
||
else:
|
||
self.status_msg.emit("Scan complete.")
|
||
self.completed.emit()
|
||
|
||
|
||
# ── Main window ───────────────────────────────────────────────────────────────
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
uic.loadUi(ROOT / "sc3-aui-main.ui", self)
|
||
self.setWindowTitle("Scanengine-3 AUI")
|
||
|
||
# ── Worker threads ────────────────────────────────────────────────────
|
||
self._t3r_thread = QThread(self)
|
||
self._t3r_worker = T3RWorker()
|
||
self._t3r_worker.moveToThread(self._t3r_thread)
|
||
self._t3r_thread.started.connect(self._t3r_worker.run)
|
||
|
||
self._bbd_thread = QThread(self)
|
||
self._bbd_worker = BBD202Worker()
|
||
self._bbd_worker.moveToThread(self._bbd_thread)
|
||
self._bbd_thread.started.connect(self._bbd_worker.run)
|
||
|
||
self._oscope_thread = QThread(self)
|
||
self._oscope_worker = OscopeWorker()
|
||
self._oscope_worker.moveToThread(self._oscope_thread)
|
||
self._oscope_thread.started.connect(self._oscope_worker.run)
|
||
|
||
# ── Popup windows ─────────────────────────────────────────────────────
|
||
self._camera_win = CameraWindow()
|
||
self._scan_progress = ScanProgressWindow()
|
||
|
||
self._scan_worker: ScanWorker | None = None
|
||
self._scan_thread: QThread | None = None
|
||
self._t3r_axes_enabled: set[int] = set()
|
||
|
||
# Continuous-jog timers — fire repeatedly while button is held
|
||
self._t3r_active_jog: tuple[int, int] | None = None # (axis, direction)
|
||
self._t3r_jog_timer = QTimer(self)
|
||
self._t3r_jog_timer.setInterval(250) # ms between repeat steps
|
||
self._t3r_jog_timer.timeout.connect(self._t3r_jog_tick)
|
||
|
||
self._bbd_active_jog: tuple[str, int] | None = None # (axis, direction)
|
||
self._bbd_jog_timer = QTimer(self)
|
||
self._bbd_jog_timer.setInterval(200)
|
||
self._bbd_jog_timer.timeout.connect(self._bbd_jog_tick)
|
||
|
||
self._init_ui_fields()
|
||
self._wire_signals()
|
||
|
||
self._t3r_thread.start()
|
||
self._bbd_thread.start()
|
||
self._oscope_thread.start()
|
||
|
||
# ── UI initialisation ─────────────────────────────────────────────────────
|
||
|
||
def _init_ui_fields(self):
|
||
self.t3r_comport_edit.setText(DEFAULT_T3R_PORT)
|
||
self.bbd202_comport_edit.setText(DEFAULT_BBD_PORT)
|
||
self.oscope_ip_edit.setText(DEFAULT_OSCOPE_IP)
|
||
self.t3r_jog_speed_edit.setText(str(GR_MOVE_SPEED_HZ))
|
||
self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM))
|
||
|
||
self.x_start_edit.setText("10.000")
|
||
self.y_start_edit.setText("10.000")
|
||
self.x_delta_edit.setText("80.000")
|
||
self.y_delta_edit.setText("50.000")
|
||
self.num_angles_edit.setText("1")
|
||
self.row_spacing_edit.setText("0.250")
|
||
self.scan_prefix_edit.setText("scan")
|
||
self.scan_save_dir_edit.setText(DEFAULT_SAVE_DIR)
|
||
|
||
self._set_t3r_controls_enabled(False)
|
||
self._set_bbd_controls_enabled(False)
|
||
|
||
# ── Signal wiring ─────────────────────────────────────────────────────────
|
||
|
||
def _wire_signals(self):
|
||
# T3R
|
||
self.t3r_connect_toggle.toggled.connect(self._on_t3r_toggle)
|
||
self._t3r_worker.connected.connect(self._on_t3r_connected)
|
||
self._t3r_worker.disconnected.connect(self._on_t3r_disconnected)
|
||
self._t3r_worker.connection_failed.connect(self._on_t3r_failed)
|
||
|
||
self.t3r_enable_t1_btn.toggled.connect(lambda on: self._on_t3r_enable_axis(1, on))
|
||
self.t3r_enable_t2_btn.toggled.connect(lambda on: self._on_t3r_enable_axis(2, on))
|
||
self.t3r_enable_t3_btn.toggled.connect(lambda on: self._on_t3r_enable_axis(3, on))
|
||
self.t3r_enable_gr_btn.toggled.connect(lambda on: self._on_t3r_enable_axis(4, on))
|
||
|
||
self.jog_t1_up_btn.pressed.connect(lambda: self._start_t3r_jog(1, 1))
|
||
self.jog_t1_up_btn.released.connect(self._stop_t3r_jog)
|
||
self.jog_t1_down_btn.pressed.connect(lambda: self._start_t3r_jog(1, -1))
|
||
self.jog_t1_down_btn.released.connect(self._stop_t3r_jog)
|
||
self.jog_t2_up_btn.pressed.connect(lambda: self._start_t3r_jog(2, 1))
|
||
self.jog_t2_up_btn.released.connect(self._stop_t3r_jog)
|
||
self.jog_t2_down_btn.pressed.connect(lambda: self._start_t3r_jog(2, -1))
|
||
self.jog_t2_down_btn.released.connect(self._stop_t3r_jog)
|
||
self.jog_t3_up_btn.pressed.connect(lambda: self._start_t3r_jog(3, 1))
|
||
self.jog_t3_up_btn.released.connect(self._stop_t3r_jog)
|
||
self.jog_t3_down_btn.pressed.connect(lambda: self._start_t3r_jog(3, -1))
|
||
self.jog_t3_down_btn.released.connect(self._stop_t3r_jog)
|
||
self.jog_gr_ccw_btn.pressed.connect(lambda: self._start_t3r_jog(4, -1))
|
||
self.jog_gr_ccw_btn.released.connect(self._stop_t3r_jog)
|
||
self.jog_gr_cw_btn.pressed.connect(lambda: self._start_t3r_jog(4, 1))
|
||
self.jog_gr_cw_btn.released.connect(self._stop_t3r_jog)
|
||
self.t3r_set_current_t1_btn.clicked.connect(lambda: self._set_t3r_current(1, self.t3r_current_t1_spin.value()))
|
||
self.t3r_set_current_t2_btn.clicked.connect(lambda: self._set_t3r_current(2, self.t3r_current_t2_spin.value()))
|
||
self.t3r_set_current_t3_btn.clicked.connect(lambda: self._set_t3r_current(3, self.t3r_current_t3_spin.value()))
|
||
self.t3r_set_current_gr_btn.clicked.connect(lambda: self._set_t3r_current(4, self.t3r_current_gr_spin.value()))
|
||
|
||
# BBD202
|
||
self.bbd202_connect_toggle.toggled.connect(self._on_bbd_toggle)
|
||
self._bbd_worker.connected.connect(self._on_bbd_connected)
|
||
self._bbd_worker.disconnected.connect(self._on_bbd_disconnected)
|
||
self._bbd_worker.connection_failed.connect(self._on_bbd_failed)
|
||
self._bbd_worker.position_updated.connect(self._on_bbd_position)
|
||
self._bbd_worker.error_occurred.connect(
|
||
lambda m: print(f"[BBD] {m}")
|
||
)
|
||
|
||
self.bbd_home_all_btn.clicked.connect(lambda: self._bbd_worker.queue_home_all())
|
||
self.bbd_enable_all_btn.clicked.connect(lambda: self._bbd_worker.queue_enable_all())
|
||
self.bbd_jog_x_pos_btn.pressed.connect(lambda: self._start_bbd_jog("x", 1))
|
||
self.bbd_jog_x_pos_btn.released.connect(self._stop_bbd_jog)
|
||
self.bbd_jog_x_neg_btn.pressed.connect(lambda: self._start_bbd_jog("x", -1))
|
||
self.bbd_jog_x_neg_btn.released.connect(self._stop_bbd_jog)
|
||
self.bbd_jog_y_pos_btn.pressed.connect(lambda: self._start_bbd_jog("y", 1))
|
||
self.bbd_jog_y_pos_btn.released.connect(self._stop_bbd_jog)
|
||
self.bbd_jog_y_neg_btn.pressed.connect(lambda: self._start_bbd_jog("y", -1))
|
||
self.bbd_jog_y_neg_btn.released.connect(self._stop_bbd_jog)
|
||
self.bbd_set_current_start_btn.clicked.connect(self._on_set_start_from_pos)
|
||
self.bbd_set_delta_current_btn.clicked.connect(self._on_calc_delta)
|
||
|
||
# Oscilloscope
|
||
self.oscope_connect_toggle.toggled.connect(self._on_oscope_toggle)
|
||
self._oscope_worker.connected.connect(self._on_oscope_connected)
|
||
self._oscope_worker.disconnected.connect(self._on_oscope_disconnected)
|
||
self._oscope_worker.connection_failed.connect(self._on_oscope_failed)
|
||
|
||
# Persist port fields to aui_defaults.json on change
|
||
self.t3r_comport_edit.editingFinished.connect(self._persist_defaults)
|
||
self.bbd202_comport_edit.editingFinished.connect(self._persist_defaults)
|
||
self.oscope_ip_edit.editingFinished.connect(self._persist_defaults)
|
||
|
||
# Camera
|
||
self.show_camera_toggle.toggled.connect(self._on_camera_toggle)
|
||
self._camera_win.uc480_close_window_btn.clicked.connect(
|
||
lambda: self.show_camera_toggle.setChecked(False)
|
||
)
|
||
|
||
# Scan
|
||
self.start_scan_btn.clicked.connect(self._on_start_scan)
|
||
self.save_dir_browse_btn.clicked.connect(self._on_browse_save_dir)
|
||
self._scan_progress.abort_requested.connect(self._on_abort_scan)
|
||
|
||
# ── T3R slots ─────────────────────────────────────────────────────────────
|
||
|
||
def _on_t3r_toggle(self, checked: bool):
|
||
if checked:
|
||
self.t3r_connect_toggle.setText("Connecting…")
|
||
self.t3r_connect_toggle.setEnabled(False)
|
||
self._t3r_worker.queue_connect(self.t3r_comport_edit.text().strip())
|
||
else:
|
||
self._t3r_worker.queue_disconnect()
|
||
|
||
def _on_t3r_connected(self):
|
||
self.t3r_connect_toggle.setEnabled(True)
|
||
self.t3r_connect_toggle.setText("Disconnect")
|
||
self._set_t3r_controls_enabled(True)
|
||
|
||
def _on_t3r_disconnected(self):
|
||
self._set_toggle(self.t3r_connect_toggle, False, "Connect")
|
||
self._set_t3r_controls_enabled(False)
|
||
self._t3r_axes_enabled = set()
|
||
for axis in (1, 2, 3, 4):
|
||
btn = self._t3r_axis_enable_btn(axis)
|
||
btn.blockSignals(True)
|
||
btn.setChecked(False)
|
||
btn.setText(f"Enable {_T3R_AXIS_NAMES[axis]}")
|
||
btn.blockSignals(False)
|
||
|
||
def _on_t3r_failed(self, msg: str):
|
||
self._set_toggle(self.t3r_connect_toggle, False, "Connect")
|
||
self.t3r_connect_toggle.setEnabled(True)
|
||
QMessageBox.warning(self, "T3R Connection Failed", msg)
|
||
|
||
def _t3r_axis_enable_btn(self, axis: int):
|
||
return {1: self.t3r_enable_t1_btn, 2: self.t3r_enable_t2_btn,
|
||
3: self.t3r_enable_t3_btn, 4: self.t3r_enable_gr_btn}[axis]
|
||
|
||
def _on_t3r_enable_axis(self, axis: int, enabled: bool):
|
||
if enabled:
|
||
self._t3r_axes_enabled.add(axis)
|
||
else:
|
||
self._t3r_axes_enabled.discard(axis)
|
||
self._t3r_worker.queue_enable(axis, enabled)
|
||
name = _T3R_AXIS_NAMES[axis]
|
||
self._t3r_axis_enable_btn(axis).setText(
|
||
f"{'Disable' if enabled else 'Enable'} {name}"
|
||
)
|
||
|
||
def _start_t3r_jog(self, axis: int, direction: int):
|
||
if axis not in self._t3r_axes_enabled:
|
||
return
|
||
self._t3r_active_jog = (axis, direction)
|
||
self._t3r_jog_tick()
|
||
self._t3r_jog_timer.start()
|
||
|
||
def _t3r_jog_tick(self):
|
||
if self._t3r_active_jog is None:
|
||
return
|
||
axis, direction = self._t3r_active_jog
|
||
try:
|
||
speed = int(self.t3r_jog_speed_edit.text())
|
||
except ValueError:
|
||
speed = T1T2T3_JOG_STEPS
|
||
steps = (GR_JOG_STEPS if axis == 4 else T1T2T3_JOG_STEPS) * direction
|
||
self._t3r_worker.queue_move(axis, steps, speed)
|
||
|
||
def _stop_t3r_jog(self):
|
||
self._t3r_jog_timer.stop()
|
||
self._t3r_active_jog = None
|
||
|
||
def _set_t3r_current(self, axis: int, ma: int):
|
||
self._t3r_worker.queue_set_current(axis, ma)
|
||
|
||
def _set_t3r_controls_enabled(self, on: bool):
|
||
for w in (
|
||
self.t3r_enable_t1_btn, self.t3r_enable_t2_btn,
|
||
self.t3r_enable_t3_btn, self.t3r_enable_gr_btn,
|
||
self.jog_t1_up_btn, self.jog_t1_down_btn,
|
||
self.jog_t2_up_btn, self.jog_t2_down_btn,
|
||
self.jog_t3_up_btn, self.jog_t3_down_btn,
|
||
self.jog_gr_ccw_btn, self.jog_gr_cw_btn,
|
||
self.t3r_set_current_t1_btn, self.t3r_set_current_t2_btn,
|
||
self.t3r_set_current_t3_btn, self.t3r_set_current_gr_btn,
|
||
):
|
||
w.setEnabled(on)
|
||
|
||
# ── BBD202 slots ──────────────────────────────────────────────────────────
|
||
|
||
def _on_bbd_toggle(self, checked: bool):
|
||
if checked:
|
||
self.bbd202_connect_toggle.setText("Connecting…")
|
||
self.bbd202_connect_toggle.setEnabled(False)
|
||
self._bbd_worker.queue_connect(self.bbd202_comport_edit.text().strip())
|
||
else:
|
||
self._bbd_worker.queue_disconnect()
|
||
|
||
def _on_bbd_connected(self):
|
||
self.bbd202_connect_toggle.setEnabled(True)
|
||
self.bbd202_connect_toggle.setText("Disconnect")
|
||
self._set_bbd_controls_enabled(True)
|
||
|
||
def _on_bbd_disconnected(self):
|
||
self._set_toggle(self.bbd202_connect_toggle, False, "Connect")
|
||
self._set_bbd_controls_enabled(False)
|
||
self.bbd_current_x_position_indicator.setText("---.-")
|
||
self.bbd_current_y_position_indicator.setText("---.-")
|
||
|
||
def _on_bbd_failed(self, msg: str):
|
||
self._set_toggle(self.bbd202_connect_toggle, False, "Connect")
|
||
self.bbd202_connect_toggle.setEnabled(True)
|
||
QMessageBox.warning(self, "BBD202 Connection Failed", msg)
|
||
|
||
def _on_bbd_position(self, x: float, y: float):
|
||
self.bbd_current_x_position_indicator.setText(f"{x:07.3f}")
|
||
self.bbd_current_y_position_indicator.setText(f"{y:07.3f}")
|
||
|
||
def _start_bbd_jog(self, axis: str, direction: int):
|
||
self._bbd_active_jog = (axis, direction)
|
||
self._bbd_jog_tick()
|
||
self._bbd_jog_timer.start()
|
||
|
||
def _bbd_jog_tick(self):
|
||
if self._bbd_active_jog is None:
|
||
return
|
||
axis, direction = self._bbd_active_jog
|
||
try:
|
||
self._bbd_worker._jog_step = float(self.lineEdit_10.text())
|
||
except ValueError:
|
||
self._bbd_worker._jog_step = BBD_DEFAULT_JOG_MM
|
||
self._bbd_worker.queue_jog(axis, direction)
|
||
|
||
def _stop_bbd_jog(self):
|
||
self._bbd_jog_timer.stop()
|
||
self._bbd_active_jog = None
|
||
|
||
def _on_set_start_from_pos(self):
|
||
try:
|
||
x = float(self.bbd_current_x_position_indicator.text())
|
||
y = float(self.bbd_current_y_position_indicator.text())
|
||
self.x_start_edit.setText(f"{x:.3f}")
|
||
self.y_start_edit.setText(f"{y:.3f}")
|
||
except ValueError:
|
||
pass
|
||
|
||
def _on_calc_delta(self):
|
||
try:
|
||
xs = float(self.x_start_edit.text())
|
||
ys = float(self.y_start_edit.text())
|
||
cx = float(self.bbd_current_x_position_indicator.text())
|
||
cy = float(self.bbd_current_y_position_indicator.text())
|
||
self.x_delta_edit.setText(f"{abs(cx - xs):.3f}")
|
||
self.y_delta_edit.setText(f"{abs(cy - ys):.3f}")
|
||
except ValueError:
|
||
pass
|
||
|
||
def _set_bbd_controls_enabled(self, on: bool):
|
||
for w in (
|
||
self.bbd_home_all_btn, self.bbd_enable_all_btn,
|
||
self.bbd_jog_x_pos_btn, self.bbd_jog_x_neg_btn,
|
||
self.bbd_jog_y_pos_btn, self.bbd_jog_y_neg_btn,
|
||
self.bbd_set_current_start_btn, self.bbd_set_delta_current_btn,
|
||
):
|
||
w.setEnabled(on)
|
||
|
||
# ── Oscilloscope slots ────────────────────────────────────────────────────
|
||
|
||
def _on_oscope_toggle(self, checked: bool):
|
||
if checked:
|
||
self.oscope_connect_toggle.setText("Connecting…")
|
||
self.oscope_connect_toggle.setEnabled(False)
|
||
self._oscope_worker.queue_connect(self.oscope_ip_edit.text().strip())
|
||
else:
|
||
self._oscope_worker.queue_disconnect()
|
||
|
||
def _on_oscope_connected(self):
|
||
self.oscope_connect_toggle.setEnabled(True)
|
||
self.oscope_connect_toggle.setText("Disconnect")
|
||
|
||
def _on_oscope_disconnected(self):
|
||
self._set_toggle(self.oscope_connect_toggle, False, "Connect")
|
||
|
||
def _on_oscope_failed(self, msg: str):
|
||
self._set_toggle(self.oscope_connect_toggle, False, "Connect")
|
||
self.oscope_connect_toggle.setEnabled(True)
|
||
QMessageBox.warning(self, "Oscilloscope Connection Failed", msg)
|
||
|
||
# ── Persist defaults ──────────────────────────────────────────────────────
|
||
|
||
def _persist_defaults(self):
|
||
_save_aui_defaults({
|
||
"t3r_port": self.t3r_comport_edit.text().strip(),
|
||
"bbd_port": self.bbd202_comport_edit.text().strip(),
|
||
"oscope_ip": self.oscope_ip_edit.text().strip(),
|
||
"laser_freq_hz": DEFAULT_LASER_FREQ_HZ,
|
||
"save_dir": self.scan_save_dir_edit.text().strip(),
|
||
})
|
||
|
||
# ── Camera toggle ─────────────────────────────────────────────────────────
|
||
|
||
def _on_camera_toggle(self, checked: bool):
|
||
if checked:
|
||
self.show_camera_toggle.setText("Hide Camera Window")
|
||
self._camera_win.show()
|
||
else:
|
||
self.show_camera_toggle.setText("Show Camera Window")
|
||
self._camera_win.close()
|
||
|
||
# ── Scan ──────────────────────────────────────────────────────────────────
|
||
|
||
def _on_browse_save_dir(self):
|
||
d = QFileDialog.getExistingDirectory(
|
||
self, "Select Scan Save Directory", self.scan_save_dir_edit.text()
|
||
)
|
||
if d:
|
||
self.scan_save_dir_edit.setText(d)
|
||
self._persist_defaults()
|
||
|
||
def _on_start_scan(self):
|
||
try:
|
||
params = self._build_scan_params()
|
||
except ValueError as e:
|
||
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
|
||
return
|
||
|
||
# ── Launch scan worker ────────────────────────────────────────────────
|
||
self._scan_thread = QThread(self)
|
||
self._scan_worker = ScanWorker(
|
||
self._bbd_worker, self._t3r_worker, self._oscope_worker, params
|
||
)
|
||
self._scan_worker.moveToThread(self._scan_thread)
|
||
self._scan_thread.started.connect(self._scan_worker.run)
|
||
self._scan_worker.row_done.connect(self._on_row_done)
|
||
self._scan_worker.completed.connect(self._on_scan_complete)
|
||
self._scan_worker.failed.connect(self._on_scan_failed)
|
||
self._scan_worker.status_msg.connect(lambda m: print(f"[SCAN] {m}"))
|
||
self._scan_worker.user_prompt.connect(self._on_scan_user_prompt)
|
||
|
||
self.start_scan_btn.setEnabled(False)
|
||
self._scan_progress.update_progress(0, params["n_rows"], 0, params["num_angles"])
|
||
self._scan_progress.show()
|
||
self._scan_thread.start()
|
||
|
||
def _build_scan_params(self) -> dict:
|
||
def _f(w, label):
|
||
try:
|
||
return float(w.text())
|
||
except ValueError:
|
||
raise ValueError(f"'{label}' is not a valid number: {w.text()!r}")
|
||
def _i(w, label):
|
||
try:
|
||
return int(w.text())
|
||
except ValueError:
|
||
raise ValueError(f"'{label}' is not a valid integer: {w.text()!r}")
|
||
|
||
x_start = _f(self.x_start_edit, "XS")
|
||
y_start = _f(self.y_start_edit, "YS")
|
||
x_delta = _f(self.x_delta_edit, "XD")
|
||
y_delta = _f(self.y_delta_edit, "YD")
|
||
num_angles = _i(self.num_angles_edit, "NumAngles")
|
||
row_spacing = _f(self.row_spacing_edit,"RowSpacing")
|
||
prefix = self.scan_prefix_edit.text().strip() or "scan"
|
||
save_dir = self.scan_save_dir_edit.text().strip() or DEFAULT_SAVE_DIR
|
||
|
||
if x_delta <= 0:
|
||
raise ValueError("XD must be > 0")
|
||
if row_spacing <= 0:
|
||
raise ValueError("RowSpacing must be > 0")
|
||
if num_angles < 1:
|
||
raise ValueError("NumAngles must be ≥ 1")
|
||
|
||
n_rows = max(1, round(y_delta / row_spacing) + 1) if y_delta > 0 else 1
|
||
|
||
return {
|
||
"x_start": x_start, "y_start": y_start,
|
||
"x_delta": x_delta, "y_delta": y_delta,
|
||
"num_angles": num_angles,
|
||
"row_spacing": row_spacing,
|
||
"laser_freq": DEFAULT_LASER_FREQ_HZ,
|
||
"prefix": prefix,
|
||
"save_dir": save_dir,
|
||
"n_rows": n_rows,
|
||
}
|
||
|
||
def _on_row_done(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
|
||
self._scan_progress.update_progress(row, n_rows, angle_idx, n_angles)
|
||
|
||
def _on_scan_complete(self):
|
||
self._scan_progress.close()
|
||
self.start_scan_btn.setEnabled(True)
|
||
QMessageBox.information(
|
||
self, "Scan Complete",
|
||
"All rows and angles have been acquired.\n\n"
|
||
"Hardware will now be disconnected and the application will close."
|
||
)
|
||
self._disconnect_all_and_close()
|
||
|
||
def _on_scan_failed(self, msg: str):
|
||
self._scan_progress.close()
|
||
self.start_scan_btn.setEnabled(True)
|
||
if "aborted" in msg.lower():
|
||
QMessageBox.warning(self, "Scan Aborted", msg)
|
||
else:
|
||
QMessageBox.critical(self, "Scan Error", f"Scan stopped with an error:\n\n{msg}")
|
||
|
||
def _on_scan_user_prompt(self, title: str, message: str):
|
||
QMessageBox.information(self, title, message)
|
||
if self._scan_worker:
|
||
self._scan_worker.acknowledge_prompt()
|
||
|
||
def _on_abort_scan(self):
|
||
if self._scan_worker:
|
||
self._scan_worker.abort()
|
||
|
||
def _disconnect_all_and_close(self):
|
||
"""Cleanly disconnect all hardware then quit."""
|
||
if self._t3r_worker.is_connected:
|
||
self._t3r_worker.queue_disconnect()
|
||
if self._bbd_worker.is_connected:
|
||
self._bbd_worker.queue_disconnect()
|
||
if self._oscope_worker.is_connected:
|
||
self._oscope_worker.queue_disconnect()
|
||
self._camera_win.close()
|
||
QTimer.singleShot(1500, QApplication.instance().quit)
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _set_toggle(btn, checked: bool, text: str):
|
||
"""Silently update a checkable button without triggering its toggled signal."""
|
||
btn.blockSignals(True)
|
||
btn.setChecked(checked)
|
||
btn.setText(text)
|
||
btn.setEnabled(True)
|
||
btn.blockSignals(False)
|
||
|
||
# ── Cleanup ───────────────────────────────────────────────────────────────
|
||
|
||
def closeEvent(self, event):
|
||
self._camera_win.close()
|
||
self._scan_progress.close()
|
||
for w in (self._t3r_worker, self._bbd_worker, self._oscope_worker):
|
||
w.stop_worker()
|
||
for t in (self._t3r_thread, self._bbd_thread, self._oscope_thread):
|
||
t.quit()
|
||
t.wait(2000)
|
||
super().closeEvent(event)
|
||
|
||
|
||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
app = QApplication(sys.argv)
|
||
app.setStyle("Fusion")
|
||
win = MainWindow()
|
||
win.show()
|
||
sys.exit(app.exec())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|