Files
scanengine-3/sc3_aui_app.py
T
Thomas Ales [M S E] a465a3dd72 added all the other shit
2026-06-14 18:54:03 -05:00

1995 lines
82 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 numpy as np
import serial
from PyQt6 import uic
from PyQt6.QtCore import QObject, QThread, QTimer, Qt, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QImage, QPixmap, QPainter, QColor, QPen
from PyQt6.QtWidgets import (
QApplication, QFileDialog, QLabel, QMainWindow, QMessageBox, QSizePolicy,
QWidget,
)
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
ROOT = Path(__file__).parent
sys.path.insert(0, str(ROOT))
from hardware.helios_laser import HeliosLaser
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"),
"helios_port": "/dev/ttyUSB2",
}
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"]
DEFAULT_HELIOS_PORT = _aui.get("helios_port", "/dev/ttyUSB2")
# ── 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
class DCBiasImageWidget(FigureCanvas):
"""Live 2D DC-bias image: rows × frames, matching sras_viewer's compute_dc_image view."""
def __init__(self, parent=None):
fig = Figure(tight_layout=True)
super().__init__(fig)
self.setParent(parent)
self.setMinimumSize(300, 150)
self.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding,
)
self.ax = fig.add_subplot(111)
self._row_data: list[np.ndarray] = []
self._im = None
self.ax.set_title("DC Bias Preview (ADC counts)")
self.ax.set_xlabel("Frame")
self.ax.set_ylabel("Row")
self.ax.text(0.5, 0.5, "Waiting for data…",
transform=self.ax.transAxes, ha="center", va="center", color="gray")
self.draw()
def add_row(self, row_index: int, values):
"""Store per-frame DC mean values for row_index (1-based) and refresh the image."""
idx = row_index - 1
while len(self._row_data) <= idx:
self._row_data.append(np.empty(0, dtype=np.float32))
self._row_data[idx] = np.asarray(values, dtype=np.float32)
# Build rectangular image — pad shorter rows with NaN so they render distinctly
max_len = max(len(r) for r in self._row_data)
img = np.full((len(self._row_data), max_len), np.nan, dtype=np.float32)
for i, row in enumerate(self._row_data):
img[i, :len(row)] = row
self.ax.clear()
self.ax.set_title("DC Bias Preview (ADC counts)")
self.ax.set_xlabel("Frame")
self.ax.set_ylabel("Row")
self.ax.imshow(
img, aspect="auto", origin="upper",
cmap="viridis", interpolation="nearest",
)
self.draw_idle()
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 _format_eta(secs: float) -> str:
secs = max(0.0, secs)
m, s = divmod(int(secs), 60)
h, m = divmod(m, 60)
if h > 0:
return f"{h}h {m:02d}m"
if m > 0:
return f"{m}m {s:02d}s"
return f"{s}s"
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.07) # 70 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) # 100 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"})
# ── Helios laser worker ───────────────────────────────────────────────────────
class HeliosWorker(QObject):
"""Manages HeliosLaser in a worker thread using a command queue."""
connected = pyqtSignal()
disconnected = pyqtSignal()
connection_failed = pyqtSignal(str)
enabled_updated = pyqtSignal(bool)
current_updated = pyqtSignal(int)
diode_temp_updated = pyqtSignal(float)
pstage_temp_updated = pyqtSignal(float)
qswitch_temp_updated = pyqtSignal(float)
status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None)
error_occurred = pyqtSignal(str)
def __init__(self):
super().__init__()
self._laser: HeliosLaser | 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)
self._dispatch(cmd)
except queue.Empty:
pass
if self._laser:
try:
self._laser.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 == "set_enable":
self._do_set_enable(cmd["enable"])
elif t == "set_current":
self._do_set_current(cmd["current_ma"])
elif t == "poll_status":
self._do_poll_status()
elif t == "stop":
self._running = False
def _do_connect(self, port: str):
try:
self._laser = HeliosLaser(timeout=1.0)
if not self._laser.connect(port):
self._laser = None
self.connection_failed.emit("Failed to open serial port")
return
self.is_connected = True
self.connected.emit()
self._do_poll_status()
except Exception as e:
self._laser = None
self.connection_failed.emit(str(e))
def _do_disconnect(self):
if self._laser:
try:
self._laser.set_laser_enable(False)
except Exception:
pass
try:
self._laser.disconnect()
except Exception:
pass
self._laser = None
self.is_connected = False
self.disconnected.emit()
def _do_set_enable(self, enable: bool):
if not self._laser:
return
try:
self._laser.set_laser_enable(enable)
self.enabled_updated.emit(self._laser.is_laser_enabled())
except Exception as e:
self.error_occurred.emit(str(e))
def _do_set_current(self, current_ma: int):
if not self._laser:
return
try:
self._laser.set_current_ma(current_ma)
self.current_updated.emit(current_ma)
except Exception as e:
self.error_occurred.emit(str(e))
def _do_poll_status(self):
if not self._laser or not self._laser.is_connected:
return
try:
ler, lce, cce = self._laser.get_status_registers()
self.status_registers_updated.emit(ler, lce, cce)
except Exception:
pass
try:
self.enabled_updated.emit(self._laser.is_laser_enabled())
except Exception:
pass
try:
current = self._laser.get_current_ma()
if current is not None:
self.current_updated.emit(current)
except Exception:
pass
try:
t = self._laser.get_diode_temp_c()
if t is not None:
self.diode_temp_updated.emit(t)
except Exception:
pass
try:
t = self._laser.get_power_stage_temp_c()
if t is not None:
self.pstage_temp_updated.emit(t)
except Exception:
pass
try:
t = self._laser.get_qswitch_temp_c()
if t is not None:
self.qswitch_temp_updated.emit(t)
except Exception:
pass
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_set_enable(self, enable: bool):
self._cmd_q.put({"type": "set_enable", "enable": enable})
def queue_set_current(self, current_ma: int):
self._cmd_q.put({"type": "set_current", "current_ma": current_ma})
def queue_poll_status(self):
self._cmd_q.put({"type": "poll_status"})
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."""
closed = pyqtSignal()
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_start_btn.clicked.connect(self._start_stream)
self.uc480_stop_btn.clicked.connect(self._stop_stream)
self.uc480_close_window_btn.clicked.connect(self.close)
self._update_controls()
def showEvent(self, event):
super().showEvent(event)
self._connect_camera()
self._start_stream()
def closeEvent(self, event):
self._stop_stream()
self._disconnect_camera()
super().closeEvent(event)
self.closed.emit()
def _connect_camera(self):
if self._camera 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)
except Exception as e:
self._camera = None
self._frame_label.setText(f"Camera error:\n{e}")
self._update_controls()
def _disconnect_camera(self):
if self._camera:
try:
self._camera.cleanup()
except Exception:
pass
self._camera = None
self._update_controls()
def _start_stream(self):
if self._stream is not None or self._camera is None:
return
self._stream = CameraStreamThread(self._camera)
self._stream.frame_ready.connect(self._on_frame)
self._stream.start()
self._update_controls()
def _stop_stream(self):
if self._stream:
self._stream.stop()
self._stream = None
self._update_controls()
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
def _update_controls(self):
camera_ok = self._camera is not None
streaming = self._stream is not None
self.uc480_start_btn.setEnabled(camera_ok and not streaming)
self.uc480_stop_btn.setEnabled(streaming)
self.uc480_exposure_slider.setEnabled(camera_ok)
self.uc480_gain_slider.setEnabled(camera_ok)
# ── Helios laser panel ────────────────────────────────────────────────────────
class HeliosWindow(QWidget):
"""Helios laser control panel — shown/hidden with a toggle in the main window."""
closed = pyqtSignal()
def __init__(self, worker: HeliosWorker, parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Window)
uic.loadUi(ROOT / "sc3-aui-helios.ui", self)
self.setWindowTitle("Helios Laser")
self._worker = worker
self._poll_timer = QTimer(self)
self._poll_timer.setInterval(1000)
self._poll_timer.timeout.connect(self._worker.queue_poll_status)
self._wire_signals()
self._update_controls(False)
self._set_emission_indicator(False)
def closeEvent(self, event):
self._poll_timer.stop()
if self._worker.is_connected:
self._worker.queue_disconnect()
super().closeEvent(event)
self.closed.emit()
def _wire_signals(self):
self.helios_connect_toggle.toggled.connect(self._on_connect_toggle)
self._worker.connected.connect(self._on_connected)
self._worker.disconnected.connect(self._on_disconnected)
self._worker.connection_failed.connect(self._on_connection_failed)
self.helios_enable_btn.toggled.connect(self._on_enable_toggle)
self._worker.enabled_updated.connect(self._on_enabled_updated)
self.helios_set_current_btn.clicked.connect(self._on_set_current)
self._worker.current_updated.connect(self._on_current_updated)
self._worker.diode_temp_updated.connect(
lambda t: self.helios_temp_diode_label.setText(f"{t:.1f} °C")
)
self._worker.pstage_temp_updated.connect(
lambda t: self.helios_temp_pstage_label.setText(f"{t:.1f} °C")
)
self._worker.qswitch_temp_updated.connect(
lambda t: self.helios_temp_qswitch_label.setText(f"{t:.1f} °C")
)
self._worker.status_registers_updated.connect(self._on_status_registers)
self._worker.error_occurred.connect(
lambda m: self.helios_status_label.setText(m)
)
def _on_connect_toggle(self, checked: bool):
if checked:
self.helios_connect_toggle.setText("Connecting…")
self.helios_connect_toggle.setEnabled(False)
port = self.helios_port_edit.text().strip()
d = _load_aui_defaults()
d["helios_port"] = port
_save_aui_defaults(d)
self._worker.queue_connect(port)
else:
self._poll_timer.stop()
self._worker.queue_disconnect()
def _on_connected(self):
self.helios_connect_toggle.setEnabled(True)
self.helios_connect_toggle.setText("Disconnect")
self.helios_status_label.setText("Connected")
self._update_controls(True)
self._poll_timer.start()
def _on_disconnected(self):
self._poll_timer.stop()
self.helios_connect_toggle.blockSignals(True)
self.helios_connect_toggle.setChecked(False)
self.helios_connect_toggle.setText("Connect")
self.helios_connect_toggle.setEnabled(True)
self.helios_connect_toggle.blockSignals(False)
self.helios_status_label.setText("Disconnected")
self._update_controls(False)
def _on_connection_failed(self, msg: str):
self.helios_connect_toggle.blockSignals(True)
self.helios_connect_toggle.setChecked(False)
self.helios_connect_toggle.setText("Connect")
self.helios_connect_toggle.setEnabled(True)
self.helios_connect_toggle.blockSignals(False)
self.helios_status_label.setText(f"Failed: {msg}")
self._update_controls(False)
def _set_emission_indicator(self, enabled: bool):
if enabled:
self.helios_emission_indicator.setStyleSheet(
"background-color: #ff2222; border-radius: 10px;"
)
else:
self.helios_emission_indicator.setStyleSheet(
"background-color: #444444; border-radius: 10px;"
)
def _on_enable_toggle(self, checked: bool):
self.helios_enable_btn.setText("Disable Laser" if checked else "Enable Laser")
self._worker.queue_set_enable(checked)
def _on_enabled_updated(self, enabled: bool):
self.helios_enable_btn.blockSignals(True)
self.helios_enable_btn.setChecked(enabled)
self.helios_enable_btn.setText("Disable Laser" if enabled else "Enable Laser")
self.helios_enable_btn.blockSignals(False)
self._set_emission_indicator(enabled)
def _on_set_current(self):
self._worker.queue_set_current(self.helios_current_spin.value())
def _on_current_updated(self, current_ma: int):
self.helios_current_spin.blockSignals(True)
self.helios_current_spin.setValue(current_ma)
self.helios_current_spin.blockSignals(False)
def _reset_temperatures(self):
self.helios_temp_diode_label.setText("--- °C")
self.helios_temp_pstage_label.setText("--- °C")
self.helios_temp_qswitch_label.setText("--- °C")
def _on_status_registers(self, ler, lce, cce):
self.helios_ler_label.setText(f"0x{ler:04X}" if ler is not None else "---")
self.helios_lce_label.setText(f"0x{lce:04X}" if lce is not None else "---")
self.helios_cce_label.setText(f"0x{cce:04X}" if cce is not None else "---")
def _update_controls(self, connected: bool):
self.helios_enable_btn.setEnabled(connected)
self.helios_current_spin.setEnabled(connected)
self.helios_set_current_btn.setEnabled(connected)
if not connected:
self.helios_enable_btn.blockSignals(True)
self.helios_enable_btn.setChecked(False)
self.helios_enable_btn.setText("Enable Laser")
self.helios_enable_btn.blockSignals(False)
self._set_emission_indicator(False)
self._reset_temperatures()
self.helios_ler_label.setText("---")
self.helios_lce_label.setText("---")
self.helios_cce_label.setText("---")
# ── 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)
self.bias_image_widget = DCBiasImageWidget()
insert_pos = self.verticalLayout.count() - 1
self.verticalLayout.insertWidget(insert_pos, QLabel("DC Bias Preview"))
self.verticalLayout.insertWidget(insert_pos + 1, self.bias_image_widget)
self.verticalLayout.setStretch(insert_pos + 1, 1)
self._row_start_time: float | None = None
self._row_durations: list[float] = []
self._last_angle_idx: int = -1
def on_row_started(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
self._row_start_time = time.monotonic()
def update_progress(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
n_rows = max(1, n_rows)
n_angles = max(1, n_angles)
self.current_scan_current_row_indicator.setText(f"Row {row} of {n_rows}")
self.overall_scan_current_angle_indicator.setText(f"Angle {angle_idx} of {n_angles}")
if row == 0:
self._row_durations = []
self._row_start_time = None
self._last_angle_idx = -1
self.current_scan_progbar.setMaximum(n_rows)
self.current_scan_progbar.setValue(0)
self.current_scan_progbar.setFormat("Row %v/%m")
else:
# Reset duration history when the angle changes
if angle_idx != self._last_angle_idx and self._last_angle_idx != -1:
self._row_durations = []
self._last_angle_idx = angle_idx
if self._row_start_time is not None:
self._row_durations.append(time.monotonic() - self._row_start_time)
self._row_start_time = None
self.current_scan_progbar.setMaximum(n_rows)
self.current_scan_progbar.setValue(row)
rows_left = n_rows - row
if self._row_durations and rows_left > 0:
recent = self._row_durations[-5:]
avg = sum(recent) / len(recent)
eta_str = _format_eta(avg * rows_left)
self.current_scan_progbar.setFormat(f"Row %v/%m — ETA: {eta_str}")
elif rows_left == 0:
self.current_scan_progbar.setFormat("Row %v/%m — Done")
else:
self.current_scan_progbar.setFormat("Row %v/%m")
pct_ang = round(angle_idx / n_angles * 100)
self.overall_scan_progbar.setValue(pct_ang)
def update_dc_bias_plot(self, row: int, values):
self.bias_image_widget.add_row(row, values)
# ── 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()
dc_bias_updated = pyqtSignal(int, object) # row index, list[float] per-frame DC means
failed = pyqtSignal(str)
row_started = pyqtSignal(int, int, int, int) # row, n_rows, angle_idx, n_angles
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
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.row_started.emit(ri + 1, n_rows, ai + 1, n_angles)
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)
elif ch == 4:
self.status_msg.emit(f"Fetching CH{ch} data …")
scope.set_data_source(ch)
waveforms: list[bytes] = scope.transfer_fastframe(parse=False)
if waveforms:
frame_avgs = []
for w in waveforms:
n = len(w)
frame_avgs.append(
sum(struct.unpack(f"{n}b", w)) / n if n > 0 else 0.0
)
self.dc_bias_updated.emit(ri + 1, frame_avgs)
for w in waveforms:
scan_file.write(w)
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)
self._helios_thread = QThread(self)
self._helios_worker = HeliosWorker()
self._helios_worker.moveToThread(self._helios_thread)
self._helios_thread.started.connect(self._helios_worker.run)
# ── Popup windows ─────────────────────────────────────────────────────
self._camera_win = CameraWindow()
self._helios_win = HeliosWindow(self._helios_worker)
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()
self._helios_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._helios_win.helios_port_edit.setText(DEFAULT_HELIOS_PORT)
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.closed.connect(
lambda: self._set_toggle(self.show_camera_toggle, False, "Show Camera Window")
)
# Helios
self.show_helios_toggle.toggled.connect(self._on_helios_toggle)
self._helios_win.closed.connect(
lambda: self._set_toggle(self.show_helios_toggle, False, "Show Helios Panel")
)
self._helios_win.helios_close_btn.clicked.connect(
lambda: self.show_helios_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()
# ── Helios toggle ─────────────────────────────────────────────────────────
def _on_helios_toggle(self, checked: bool):
if checked:
self.show_helios_toggle.setText("Hide Helios Panel")
self._helios_win.show()
else:
self.show_helios_toggle.setText("Show Helios Panel")
self._helios_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_started.connect(self._scan_progress.on_row_started)
self._scan_worker.row_done.connect(self._on_row_done)
self._scan_worker.dc_bias_updated.connect(self._scan_progress.update_dc_bias_plot)
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()
if self._helios_worker.is_connected:
self._helios_worker.queue_disconnect()
self._camera_win.close()
self._helios_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()
self._helios_win.close()
for w in (self._t3r_worker, self._bbd_worker, self._oscope_worker, self._helios_worker):
w.stop_worker()
for t in (self._t3r_thread, self._bbd_thread, self._oscope_thread, self._helios_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()