67aabde4b6
- uc480_camera: drop never-called _capture_paused/get_framerate (the hardware question _capture_paused encoded is now in KNOWN_ISSUES.md) - t3r_protocol: drop read_reg/write_reg/decode_reg/Reg (commands never wired into the driver) - bbd20x: drop _update0x0212 (never dispatched) and 8 of 9 unused trigger convenience wrappers; apt_constants: drop TriggerBitsStepper (servo-only rig) - ruff --fix: 35 unused imports across all apps; drop unused T3R_BAUD - genesis_core.py: quarantine warning header; docs/genesis_verification.md bench checklist for the 7 divergences vs tools/genesis_laser_gui.py Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2297 lines
97 KiB
Python
Executable File
2297 lines
97 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 numpy as np
|
||
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, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
|
||
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton,
|
||
QSizePolicy, QVBoxLayout, 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.t3r_driver import T3RDriver
|
||
from hardware.tektronix_base import TektronixOscilloscopeBase
|
||
from hardware.uc480_camera import (
|
||
CameraStreamThread, UC480Camera, find_camera_bus_conflicts,
|
||
)
|
||
from t3r_control_panel import T3RControlPanel
|
||
|
||
# ── 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
|
||
|
||
# GR-axis rotation defaults (used by ScanWorker between angles)
|
||
GR_MICROSTEPS = 8 # microsteps/full-step on GR axis (ch3)
|
||
GR_MOVE_VELOCITY = 4000 # steps/s for inter-angle moves
|
||
GR_MOVE_ACCEL = 2000 # steps/s² for inter-angle moves
|
||
GR_RUN_CURRENT_MA = 1200 # drive current while moving
|
||
GR_HOLD_CURRENT_MA = 400 # standstill current
|
||
GR_IHOLD_DELAY = 6 # run→hold current ramp delay (TMC IHOLDDELAY units)
|
||
# Sample rotates CW instead of CCW to clear wiring and avoid a stall condition.
|
||
GR_ROTATION_SIGN = -1
|
||
|
||
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 = 6
|
||
SCAN_CHANNELS = [1, 3, 4] # oscilloscope channels recorded, in order
|
||
# v6: each angle scans only the bounding box of the nominal ROI rotated by
|
||
# that angle, so x_start/x_delta/n_frames/n_rows all vary per angle and are
|
||
# no longer in the fixed header — see the per-angle geometry table.
|
||
# ">4s B H f f f f f f f I d B B"
|
||
# magic ver n_angles xs_nom ys_nom xd_nom yd_nom row_spacing vel freq spf sr bps n_channels
|
||
BLOB_HDR_FMT = ">4sBHfffffffIdBB"
|
||
|
||
|
||
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, angles: list[float], per_angle: list[dict],
|
||
x_start_nominal: float, y_start_nominal: float,
|
||
x_delta_nominal: float, y_delta_nominal: float,
|
||
row_spacing: float,
|
||
velocity: float, laser_freq: float,
|
||
samples_per_frame: int, sample_rate: float,
|
||
preambles: list[str],
|
||
background_waveform: bytes):
|
||
"""Create a new SRAS file and write the v6 global header + per-angle 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).
|
||
|
||
Each angle only scans the bounding box of the nominal
|
||
(x_start_nominal, y_start_nominal, x_delta_nominal, y_delta_nominal) ROI
|
||
rotated by that angle, so x_start, x_delta, n_frames (points/row) and
|
||
n_rows all vary per angle. `per_angle` holds one dict per angle (same
|
||
order as `angles`) with keys "x_start", "x_delta", "n_frames", "n_rows",
|
||
"y_positions".
|
||
|
||
Data is written by appending waveforms in angle-major, row-minor,
|
||
channel-inner order: for each angle, for each of its rows, for each
|
||
channel in SCAN_CHANNELS order, that angle's 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)
|
||
n_angles = len(angles)
|
||
f = open(path, "wb")
|
||
header = struct.pack(
|
||
BLOB_HDR_FMT,
|
||
BLOB_MAGIC, BLOB_VERSION,
|
||
n_angles,
|
||
x_start_nominal, y_start_nominal,
|
||
x_delta_nominal, y_delta_nominal,
|
||
row_spacing,
|
||
velocity, laser_freq,
|
||
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))
|
||
# Per-angle geometry table: x_start, x_delta, n_frames, n_rows.
|
||
for pa in per_angle:
|
||
f.write(struct.pack(">ffIH", pa["x_start"], pa["x_delta"], pa["n_frames"], pa["n_rows"]))
|
||
# Ragged row table: each angle's y_positions, concatenated in order.
|
||
for pa in per_angle:
|
||
f.write(struct.pack(f">{pa['n_rows']}f", *pa["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
|
||
|
||
|
||
def _read_sras_header(path: Path) -> dict:
|
||
"""Parse a v6 .sras file's header, per-angle geometry, and row tables.
|
||
|
||
Does not read the (potentially huge) waveform data block itself — only
|
||
enough to know each angle's geometry and the byte offset at which the
|
||
waveform data begins.
|
||
"""
|
||
with open(path, "rb") as f:
|
||
hdr_size = struct.calcsize(BLOB_HDR_FMT)
|
||
raw = f.read(hdr_size)
|
||
if len(raw) < hdr_size:
|
||
raise ValueError(f"{path.name}: file too short to contain a valid header")
|
||
(magic, version, n_angles, x_start_nominal, y_start_nominal,
|
||
x_delta_nominal, y_delta_nominal, row_spacing, velocity, laser_freq,
|
||
samples_per_frame, sample_rate, bytes_per_sample,
|
||
n_channels) = struct.unpack(BLOB_HDR_FMT, raw)
|
||
if magic != BLOB_MAGIC:
|
||
raise ValueError(f"{path.name}: not a valid SRAS file (bad magic)")
|
||
if version != BLOB_VERSION:
|
||
raise ValueError(
|
||
f"{path.name}: unsupported SRAS format version {version} "
|
||
f"(this app can only resume version {BLOB_VERSION} files)"
|
||
)
|
||
|
||
angles = list(struct.unpack(f">{n_angles}f", f.read(4 * n_angles)))
|
||
|
||
per_angle = []
|
||
for a in angles:
|
||
x_start, x_delta, n_frames, n_rows = struct.unpack(">ffIH", f.read(14))
|
||
per_angle.append({
|
||
"angle": a, "x_start": x_start, "x_delta": x_delta,
|
||
"n_frames": n_frames, "n_rows": n_rows,
|
||
})
|
||
|
||
for pa in per_angle:
|
||
n = pa["n_rows"]
|
||
pa["y_positions"] = list(struct.unpack(f">{n}f", f.read(4 * n)))
|
||
|
||
for _ in range(n_channels):
|
||
(plen,) = struct.unpack(">H", f.read(2))
|
||
f.read(plen)
|
||
|
||
(n_bg,) = struct.unpack(">I", f.read(4))
|
||
f.read(n_bg)
|
||
|
||
data_start_offset = f.tell()
|
||
|
||
return {
|
||
"version": version, "n_angles": n_angles,
|
||
"x_start_nominal": x_start_nominal, "y_start_nominal": y_start_nominal,
|
||
"x_delta_nominal": x_delta_nominal, "y_delta_nominal": y_delta_nominal,
|
||
"row_spacing": row_spacing, "velocity": velocity, "laser_freq": laser_freq,
|
||
"samples_per_frame": samples_per_frame, "sample_rate": sample_rate,
|
||
"bytes_per_sample": bytes_per_sample, "n_channels": n_channels,
|
||
"angles": angles, "per_angle": per_angle,
|
||
"data_start_offset": data_start_offset,
|
||
}
|
||
|
||
|
||
def _compute_angle_status(path: Path, info: dict) -> list[dict]:
|
||
"""Figure out, per angle, how much waveform data is actually present on
|
||
disk versus declared in the per-angle geometry table.
|
||
|
||
Waveform data is written angle-major, row-minor with a fixed number of
|
||
bytes per row (derivable from the per-angle geometry table), so this
|
||
walks the expected per-row byte counts against the actual file size.
|
||
Because the data is one contiguous stream, once an angle is found to be
|
||
short every angle after it is necessarily entirely absent too — there is
|
||
always a single "frontier" past which nothing has been written yet.
|
||
|
||
Returns a list of dicts, one per angle, each with: index, angle_deg,
|
||
n_rows (declared), row_bytes, data_offset (byte offset where this
|
||
angle's data starts), n_rows_available, and status
|
||
("OK" / "TRUNCATED" / "MISSING").
|
||
"""
|
||
actual_size = path.stat().st_size
|
||
cursor = info["data_start_offset"]
|
||
statuses = []
|
||
frontier_seen = False
|
||
for ai, pa in enumerate(info["per_angle"]):
|
||
row_bytes = info["n_channels"] * pa["n_frames"] * info["samples_per_frame"] * info["bytes_per_sample"]
|
||
n_rows = pa["n_rows"]
|
||
data_offset = cursor
|
||
if frontier_seen:
|
||
n_rows_available = 0
|
||
status = "MISSING"
|
||
else:
|
||
declared_bytes = row_bytes * n_rows
|
||
if row_bytes > 0 and cursor + declared_bytes <= actual_size:
|
||
n_rows_available = n_rows
|
||
status = "OK"
|
||
cursor += declared_bytes
|
||
else:
|
||
remaining = max(0, actual_size - cursor)
|
||
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
|
||
status = "MISSING" if n_rows_available == 0 else "TRUNCATED"
|
||
frontier_seen = True
|
||
statuses.append({
|
||
"index": ai, "angle_deg": pa["angle"], "n_rows": n_rows,
|
||
"row_bytes": row_bytes, "data_offset": data_offset,
|
||
"n_rows_available": n_rows_available, "status": status,
|
||
})
|
||
return statuses
|
||
|
||
|
||
# ── 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
|
||
|
||
# Banner warning when the camera shares a USB controller with serial
|
||
# adapters — opening any of those ports (T3R, BBD202, …) collapses
|
||
# the delivered frame rate to <2 fps (USB split-transaction
|
||
# contention), which makes focusing impossible.
|
||
self._bus_warn_label = QLabel(self)
|
||
self._bus_warn_label.setWordWrap(True)
|
||
self._bus_warn_label.setStyleSheet(
|
||
"background: #7f1d1d; color: white; padding: 6px; font-weight: bold;")
|
||
self._bus_warn_label.setVisible(False)
|
||
self.verticalLayout.insertWidget(0, self._bus_warn_label)
|
||
|
||
# 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._refresh_bus_warning()
|
||
self._update_controls()
|
||
|
||
def _refresh_bus_warning(self):
|
||
conflicts = find_camera_bus_conflicts()
|
||
if conflicts:
|
||
devs = ", ".join(
|
||
f"/dev/{name} ({product})" if product else f"/dev/{name}"
|
||
for name, _, product in conflicts
|
||
)
|
||
self._bus_warn_label.setText(
|
||
f"⚠ Camera shares a USB controller with: {devs}. Opening any of "
|
||
f"these serial ports (T3R panel, BBD202, …) will collapse the "
|
||
f"frame rate. Fix: plug the camera into a USB-3 (xHCI) port."
|
||
)
|
||
self._bus_warn_label.setVisible(bool(conflicts))
|
||
|
||
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("---")
|
||
|
||
|
||
# ── Resume angle picker ───────────────────────────────────────────────────────
|
||
|
||
class ResumeAngleDialog(QDialog):
|
||
"""Lets the operator pick which angles of a .sras file to (re)acquire.
|
||
|
||
Angles whose data is missing or truncated on disk are pre-checked;
|
||
any other angle can also be checked to force it to be redone (e.g. a
|
||
completed angle that's known to be bad).
|
||
"""
|
||
|
||
def __init__(self, statuses: list[dict], parent: QWidget | None = None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Select Angles to Rescan")
|
||
self.resize(480, 420)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.addWidget(QLabel(
|
||
"Check the angle(s) to (re)acquire. Missing/truncated angles are "
|
||
"pre-checked; check any other angle to force it to be redone."
|
||
))
|
||
|
||
self.list_widget = QListWidget(self)
|
||
for s in statuses:
|
||
label = (
|
||
f"Angle {s['index'] + 1}/{len(statuses)} — {s['angle_deg']:.2f}° — "
|
||
f"{s['n_rows_available']}/{s['n_rows']} rows — {s['status']}"
|
||
)
|
||
item = QListWidgetItem(label)
|
||
item.setData(Qt.ItemDataRole.UserRole, s["index"])
|
||
item.setCheckState(
|
||
Qt.CheckState.Checked if s["status"] != "OK" else Qt.CheckState.Unchecked
|
||
)
|
||
self.list_widget.addItem(item)
|
||
layout.addWidget(self.list_widget)
|
||
|
||
btn_row = QHBoxLayout()
|
||
select_all_btn = QPushButton("Select All")
|
||
select_none_btn = QPushButton("Select None")
|
||
select_all_btn.clicked.connect(lambda: self._set_all_checked(True))
|
||
select_none_btn.clicked.connect(lambda: self._set_all_checked(False))
|
||
btn_row.addWidget(select_all_btn)
|
||
btn_row.addWidget(select_none_btn)
|
||
btn_row.addStretch(1)
|
||
layout.addLayout(btn_row)
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
def _set_all_checked(self, checked: bool):
|
||
state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
|
||
for i in range(self.list_widget.count()):
|
||
self.list_widget.item(i).setCheckState(state)
|
||
|
||
def selected_indices(self) -> list[int]:
|
||
out = []
|
||
for i in range(self.list_widget.count()):
|
||
item = self.list_widget.item(i)
|
||
if item.checkState() == Qt.CheckState.Checked:
|
||
out.append(item.data(Qt.ItemDataRole.UserRole))
|
||
return sorted(out)
|
||
|
||
|
||
# ── Scan progress popup ───────────────────────────────────────────────────────
|
||
|
||
class ScanProgressWindow(QWidget):
|
||
abort_requested = pyqtSignal()
|
||
pause_toggled = pyqtSignal(bool) # True = pause requested, False = resume
|
||
|
||
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.pause_btn.toggled.connect(self._on_pause_btn)
|
||
self.bias_image_widget = DCBiasImageWidget()
|
||
insert_pos = self.verticalLayout.count() - 2 # above PAUSE + ABORT
|
||
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_pause_btn(self, checked: bool):
|
||
# Pause takes effect at the next row boundary; show the intermediate
|
||
# state until the worker confirms via on_worker_paused().
|
||
self.pause_btn.setText("PAUSING…" if checked else "PAUSE")
|
||
self.pause_toggled.emit(checked)
|
||
|
||
def on_worker_paused(self, paused: bool):
|
||
if paused and self.pause_btn.isChecked():
|
||
self.pause_btn.setText("RESUME")
|
||
|
||
def reset_pause_btn(self):
|
||
self.pause_btn.blockSignals(True)
|
||
self.pause_btn.setChecked(False)
|
||
self.pause_btn.setText("PAUSE")
|
||
self.pause_btn.blockSignals(False)
|
||
|
||
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
|
||
paused_changed = pyqtSignal(bool) # True while paused at a row boundary
|
||
|
||
def __init__(self, bbd: BBD202Worker, t3r: T3RDriver | None,
|
||
oscope: OscopeWorker, params: dict,
|
||
resume_info: dict | None = None):
|
||
super().__init__()
|
||
self._bbd = bbd
|
||
self._t3r = t3r
|
||
self._oscope = oscope
|
||
self._params = params
|
||
self._resume_info = resume_info
|
||
self._abort = False
|
||
self._prompt_event = threading.Event()
|
||
self._resume_event = threading.Event()
|
||
self._resume_event.set() # set = running, cleared = pause requested
|
||
|
||
def abort(self):
|
||
self._abort = True
|
||
self._resume_event.set() # unblock a paused scan so it can exit
|
||
|
||
def pause(self):
|
||
"""Request a pause; takes effect at the next row boundary."""
|
||
self._resume_event.clear()
|
||
|
||
def resume(self):
|
||
self._resume_event.set()
|
||
|
||
def _pause_point(self):
|
||
"""Block here (between rows, hardware idle) while a pause is requested."""
|
||
if self._resume_event.is_set() or self._abort:
|
||
return
|
||
self.status_msg.emit(
|
||
"Scan paused — lasers may be switched off. "
|
||
"Turn lasers back on before resuming."
|
||
)
|
||
self.paused_changed.emit(True)
|
||
while not self._resume_event.wait(0.2):
|
||
if self._abort:
|
||
break
|
||
self.paused_changed.emit(False)
|
||
if not self._abort:
|
||
self.status_msg.emit("Scan resumed.")
|
||
|
||
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_nominal = p["x_start_nominal"]
|
||
y_start_nominal = p["y_start_nominal"]
|
||
x_delta_nominal = p["x_delta_nominal"]
|
||
y_delta_nominal = p["y_delta_nominal"]
|
||
n_angles = max(1, p["num_angles"])
|
||
row_spacing = p["row_spacing"]
|
||
prefix = p["prefix"]
|
||
save_dir = Path(p["save_dir"])
|
||
|
||
# Each angle's bounding box (x_start, x_delta, n_frames, n_rows,
|
||
# y_positions) was pre-computed in _build_scan_params() from the
|
||
# nominal ROI rotated by *that* angle only, so every angle scans the
|
||
# minimum area needed to cover the ROI at its own rotation instead of
|
||
# the worst case across all angles.
|
||
per_angle = p["per_angle"]
|
||
angles = [pa["angle"] for pa in per_angle]
|
||
|
||
# ── 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
|
||
for pa in per_angle:
|
||
a_x_start, a_x_delta = pa["x_start"], pa["x_delta"]
|
||
x_move_start = a_x_start - _x_ramp_total
|
||
x_move_end = a_x_start + a_x_delta + _x_ramp_total
|
||
if x_move_start < 0.0:
|
||
raise ValueError(
|
||
f"Angle {pa['angle']:.1f}°: scan pre-ramp start ({x_move_start:.3f} mm) "
|
||
f"is below the X axis minimum (0 mm). Reduce XD/YD or move XS/YS "
|
||
f"so every rotation angle's bounding box stays on-stage "
|
||
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"Angle {pa['angle']:.1f}°: scan run-off end ({x_move_end:.3f} mm) "
|
||
f"exceeds the X axis maximum (110 mm). Reduce XD/YD or move XS/YS "
|
||
f"so every rotation angle's bounding box stays on-stage."
|
||
)
|
||
y_min = min(pa["y_positions"])
|
||
y_max = max(pa["y_positions"])
|
||
if y_min < 0.0:
|
||
raise ValueError(
|
||
f"Angle {pa['angle']:.1f}°: scan Y range starts at {y_min:.3f} mm, "
|
||
f"below the Y axis minimum (0 mm)."
|
||
)
|
||
if y_max > 75.0:
|
||
raise ValueError(
|
||
f"Angle {pa['angle']:.1f}°: scan Y range ends at {y_max:.3f} mm, "
|
||
f"exceeds the Y axis maximum (75 mm)."
|
||
)
|
||
|
||
total_rows = sum(pa["n_rows"] for pa in per_angle)
|
||
geometry_summary = ", ".join(
|
||
f"{pa['angle']:.1f}°: {pa['n_rows']} row(s) × {pa['n_frames']} pts/row"
|
||
for pa in per_angle
|
||
)
|
||
self.status_msg.emit(
|
||
f"Scan geometry: {n_angles} angle(s), {total_rows} row(s) total "
|
||
f"(per-angle bounding box) | save → {save_dir}\n{geometry_summary}"
|
||
)
|
||
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")
|
||
if n_angles > 1 and (self._t3r is None or not self._t3r.is_open):
|
||
raise RuntimeError(
|
||
f"NumAngles={n_angles} requires the T3R rotation stage (GR-axis), "
|
||
"but it is not connected. Connect T3R from the T3R panel before "
|
||
"starting a multi-angle scan, or set NumAngles to 1."
|
||
)
|
||
|
||
# ── Configure GR rotation axis ────────────────────────────────────────
|
||
# steps_for_angle() assumes GR_MICROSTEPS, so the device must be set to
|
||
# match — never rely on whatever the T3R panel (or firmware default)
|
||
# last left it at. Also set drive current and energise the channel.
|
||
if self._t3r is not None and self._t3r.is_open:
|
||
self.status_msg.emit(
|
||
f"Configuring GR axis: {GR_MICROSTEPS} µsteps, "
|
||
f"{GR_RUN_CURRENT_MA}/{GR_HOLD_CURRENT_MA} mA run/hold …"
|
||
)
|
||
gr_ch = self._t3r.GR_AXIS_CH
|
||
self._t3r.set_microstep(gr_ch, GR_MICROSTEPS)
|
||
self._t3r.set_current(gr_ch, GR_RUN_CURRENT_MA,
|
||
GR_HOLD_CURRENT_MA, GR_IHOLD_DELAY)
|
||
self._t3r.enable(gr_ch)
|
||
time.sleep(0.2)
|
||
|
||
# ── 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()
|
||
|
||
resume_info = self._resume_info
|
||
if resume_info is None:
|
||
# ── 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(
|
||
"Begin Scanning",
|
||
"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
|
||
else:
|
||
# Resuming an existing file: the background waveform and channel
|
||
# preambles it already contains are reused as-is (the format has
|
||
# no way to replace them without rewriting the whole file), so
|
||
# background capture is skipped entirely. Sanity-check that this
|
||
# scope is still producing the same record length the file was
|
||
# started with — a mismatch would silently corrupt the ragged
|
||
# per-row byte layout on append.
|
||
if samples_per_frame != resume_info["samples_per_frame"]:
|
||
raise RuntimeError(
|
||
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
|
||
f"does not match the {resume_info['samples_per_frame']} samples/frame "
|
||
f"this scan file was started with — cannot safely resume."
|
||
)
|
||
target_angles = ", ".join(str(t["ai"] + 1) for t in resume_info["targets"])
|
||
self._request_user_prompt(
|
||
"Resume Scan",
|
||
f"Resuming {resume_info['path'].name} — will (re)acquire "
|
||
f"angle(s) {target_angles} of {n_angles}.\n\n"
|
||
"Please re-home the GR axis to 0° before continuing — the scan "
|
||
"will rotate it directly from angle to angle before scanning resumes.\n\n"
|
||
"Please ensure the Genesis laser is ON,\n"
|
||
"then click OK to continue scanning."
|
||
)
|
||
if self._abort:
|
||
self.failed.emit("Scan aborted by user.")
|
||
return
|
||
|
||
# Restore SAMPLE mode and FastFrame for the actual scan. FastFrame
|
||
# count (points/row) is set per-angle in the scan loop below, since
|
||
# it depends on that angle's bounding-box X extent.
|
||
scope.write("ACQuire:STOPAfter RUNSTop")
|
||
scope.set_acquire_mode("SAMPLE")
|
||
scope.set_fastframe_state(True)
|
||
|
||
# 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) ─────────────────────────
|
||
if resume_info is not None:
|
||
targets_by_ai = {t["ai"]: t for t in resume_info["targets"]}
|
||
scan_file = open(resume_info["path"], "r+b")
|
||
self.status_msg.emit(
|
||
f"Resuming {resume_info['path'].name} — "
|
||
f"{len(targets_by_ai)} angle(s) to (re)acquire …"
|
||
)
|
||
else:
|
||
targets_by_ai = None
|
||
fname = save_dir / f"{prefix}.sras"
|
||
scan_file = _open_scan_file(
|
||
fname, angles, per_angle,
|
||
x_start_nominal, y_start_nominal, x_delta_nominal, y_delta_nominal,
|
||
row_spacing, SCAN_VELOCITY_MM_S, LASER_FREQ_HZ,
|
||
samples_per_frame, SCOPE_SAMPLE_RATE,
|
||
preambles, background_waveform,
|
||
)
|
||
|
||
# ── Scan loop ─────────────────────────────────────────────────────────
|
||
self._bbd.scanning_active = True
|
||
# Both fresh and resumed scans assume the GR axis starts at home (0°)
|
||
# — the resume prompt instructs the operator to re-home it before
|
||
# continuing — so the first move always rotates directly from 0°
|
||
# to the starting angle.
|
||
prev_gr_deg = 0.0
|
||
try:
|
||
for ai, pa in enumerate(per_angle):
|
||
if targets_by_ai is not None and ai not in targets_by_ai:
|
||
continue # not selected for (re)acquisition
|
||
self._pause_point()
|
||
if self._abort:
|
||
break
|
||
|
||
angle = pa["angle"]
|
||
a_x_start = pa["x_start"]
|
||
a_x_delta = pa["x_delta"]
|
||
a_n_rows = pa["n_rows"]
|
||
a_n_frames = pa["n_frames"]
|
||
y_positions = pa["y_positions"]
|
||
|
||
if targets_by_ai is not None:
|
||
# Interior angles may already have valid data on either
|
||
# side of them, so seek to this angle's fixed offset
|
||
# rather than relying on the file's current position.
|
||
scan_file.seek(targets_by_ai[ai]["data_offset"])
|
||
|
||
# ── Rotate GR ─────────────────────────────────────────────────
|
||
if self._t3r is not None and self._t3r.is_open:
|
||
delta_deg = angle - prev_gr_deg
|
||
if abs(delta_deg) > 0.001:
|
||
steps = self._t3r.steps_for_angle(delta_deg, GR_MICROSTEPS)
|
||
# Trapezoidal move time: cruise + accel/decel ramps
|
||
est_secs = (abs(steps) / GR_MOVE_VELOCITY
|
||
+ GR_MOVE_VELOCITY / GR_MOVE_ACCEL)
|
||
self.status_msg.emit(
|
||
f"Rotating GR to {angle:.1f}° (Δ{delta_deg:+.1f}°, "
|
||
f"≈{est_secs:.1f} s) …"
|
||
)
|
||
self._t3r.rotate_stage(delta_deg, GR_MICROSTEPS,
|
||
GR_MOVE_VELOCITY, GR_MOVE_ACCEL)
|
||
time.sleep(est_secs + 0.5)
|
||
prev_gr_deg = angle
|
||
|
||
# This angle's bounding box gives it its own points/row count,
|
||
# so the scope's FastFrame count must be re-armed per angle.
|
||
scope.set_fastframe_count(a_n_frames)
|
||
|
||
# ── Row loop ──────────────────────────────────────────────────
|
||
for ri, y_pos in enumerate(y_positions):
|
||
self._pause_point()
|
||
if self._abort:
|
||
break
|
||
|
||
self.row_started.emit(ri + 1, a_n_rows, ai + 1, n_angles)
|
||
self.status_msg.emit(
|
||
f"Angle {ai+1}/{n_angles} Row {ri+1}/{a_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, a_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 = a_x_start + a_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_acquired = int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
||
for _ in range(n_frames_acquired):
|
||
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, a_n_rows, ai + 1, n_angles)
|
||
|
||
finally:
|
||
scan_file.close()
|
||
self._bbd.scanning_active = False
|
||
# Return GR axis to home (0°) regardless of abort or error
|
||
if self._t3r is not None and self._t3r.is_open and abs(prev_gr_deg) > 0.001:
|
||
return_deg = -prev_gr_deg
|
||
est_secs = (abs(self._t3r.steps_for_angle(return_deg, GR_MICROSTEPS))
|
||
/ GR_MOVE_VELOCITY + GR_MOVE_VELOCITY / GR_MOVE_ACCEL)
|
||
self.status_msg.emit(
|
||
f"Returning GR to home ({return_deg:+.1f}°, ≈{est_secs:.1f} s) …"
|
||
)
|
||
self._t3r.rotate_stage(return_deg, GR_MICROSTEPS,
|
||
GR_MOVE_VELOCITY, GR_MOVE_ACCEL)
|
||
time.sleep(est_secs + 0.5)
|
||
|
||
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")
|
||
|
||
# ── T3R driver (owns serial + reader QThread internally) ─────────────
|
||
self._t3r_driver = T3RDriver(self)
|
||
self._t3r_panel: T3RControlPanel | None = None
|
||
|
||
# ── Worker threads ────────────────────────────────────────────────────
|
||
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._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_menu_bar()
|
||
self._init_ui_fields()
|
||
self._wire_signals()
|
||
|
||
self._bbd_thread.start()
|
||
self._oscope_thread.start()
|
||
self._helios_thread.start()
|
||
|
||
# ── Menu bar ───────────────────────────────────────────────────────────────
|
||
|
||
def _init_menu_bar(self):
|
||
scan_menu = self.menuBar().addMenu("&Scan")
|
||
self.resume_scan_action = scan_menu.addAction("&Resume Scan…")
|
||
self.resume_scan_action.triggered.connect(self._on_resume_scan_action)
|
||
|
||
# ── 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.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)
|
||
|
||
# Hide the old T3R manual controls; the connect toggle becomes the panel button.
|
||
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,
|
||
self.t3r_current_t1_spin, self.t3r_current_t2_spin,
|
||
self.t3r_current_t3_spin, self.t3r_current_gr_spin,
|
||
self.t3r_jog_speed_edit,
|
||
):
|
||
w.setVisible(False)
|
||
|
||
self.t3r_connect_toggle.setCheckable(True)
|
||
self.t3r_connect_toggle.setText("Show T3R Panel")
|
||
self._set_bbd_controls_enabled(False)
|
||
|
||
# ── Signal wiring ─────────────────────────────────────────────────────────
|
||
|
||
def _wire_signals(self):
|
||
# T3R — toggle button opens/closes the T3RControlPanel
|
||
self.t3r_connect_toggle.toggled.connect(self._on_t3r_panel_toggle)
|
||
|
||
# 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)
|
||
self._scan_progress.pause_toggled.connect(self._on_pause_scan)
|
||
|
||
# ── T3R panel toggle ──────────────────────────────────────────────────────
|
||
|
||
def _on_t3r_panel_toggle(self, checked: bool):
|
||
if checked:
|
||
self._show_t3r_panel()
|
||
elif self._t3r_panel is not None:
|
||
self._t3r_panel.hide()
|
||
self.t3r_connect_toggle.setText("Show T3R Panel")
|
||
|
||
def _show_t3r_panel(self):
|
||
if self._t3r_panel is None:
|
||
self._t3r_panel = T3RControlPanel(self._t3r_driver, self)
|
||
# Pre-fill port from the field that's still shown in the main UI
|
||
port = self.t3r_comport_edit.text().strip()
|
||
if port:
|
||
idx = self._t3r_panel.port_combo.findData(port)
|
||
if idx < 0:
|
||
self._t3r_panel.port_combo.insertItem(0, port, port)
|
||
idx = 0
|
||
self._t3r_panel.port_combo.setCurrentIndex(idx)
|
||
self._t3r_panel.finished.connect(self._on_t3r_panel_closed)
|
||
self._t3r_panel.show()
|
||
self._t3r_panel.raise_()
|
||
self.t3r_connect_toggle.setText("Hide T3R Panel")
|
||
|
||
def _on_t3r_panel_closed(self):
|
||
self._set_toggle(self.t3r_connect_toggle, False, "Show T3R Panel")
|
||
|
||
# ── 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
|
||
self._launch_scan_worker(params)
|
||
|
||
def _on_resume_scan_action(self):
|
||
if self._scan_thread is not None and self._scan_thread.isRunning():
|
||
QMessageBox.warning(
|
||
self, "Scan In Progress",
|
||
"A scan is already running — abort it before resuming another one."
|
||
)
|
||
return
|
||
|
||
start_dir = self.scan_save_dir_edit.text().strip() or DEFAULT_SAVE_DIR
|
||
path_str, _ = QFileDialog.getOpenFileName(
|
||
self, "Select Scan File to Resume", start_dir, "SRAS Scan Files (*.sras)"
|
||
)
|
||
if not path_str:
|
||
return
|
||
path = Path(path_str)
|
||
|
||
try:
|
||
info = _read_sras_header(path)
|
||
statuses = _compute_angle_status(path, info)
|
||
except (ValueError, struct.error, OSError) as e:
|
||
QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}")
|
||
return
|
||
|
||
if (info["bytes_per_sample"] != 1 or info["n_channels"] != len(SCAN_CHANNELS)
|
||
or abs(info["velocity"] - SCAN_VELOCITY_MM_S) > 1e-3
|
||
or abs(info["laser_freq"] - LASER_FREQ_HZ) > 1e-3
|
||
or abs(info["sample_rate"] - SCOPE_SAMPLE_RATE) > 1.0):
|
||
QMessageBox.warning(
|
||
self, "Cannot Resume Scan",
|
||
f"{path.name} was recorded with acquisition settings that don't "
|
||
"match this app's current fixed settings, so appending to it "
|
||
"would corrupt the file. Resume is only possible for files "
|
||
"produced by this exact version of the app."
|
||
)
|
||
return
|
||
|
||
dialog = ResumeAngleDialog(statuses, self)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
selected = set(dialog.selected_indices())
|
||
if not selected:
|
||
return
|
||
|
||
frontier_idx = next((s["index"] for s in statuses if s["status"] != "OK"), len(statuses))
|
||
at_or_past_frontier = {i for i in selected if i >= frontier_idx}
|
||
if at_or_past_frontier:
|
||
required = set(range(frontier_idx, max(at_or_past_frontier) + 1))
|
||
final = selected | required
|
||
else:
|
||
final = selected
|
||
|
||
auto_added = sorted(final - selected)
|
||
if auto_added:
|
||
QMessageBox.information(
|
||
self, "Additional Angles Required",
|
||
"The scan file has no data yet past angle "
|
||
f"{frontier_idx + 1}, so angle(s) can't be skipped over. "
|
||
"The following angle(s) will also be (re)acquired so there's "
|
||
f"no gap: {[i + 1 for i in auto_added]}"
|
||
)
|
||
|
||
targets = [
|
||
{
|
||
"ai": s["index"], "data_offset": s["data_offset"],
|
||
"n_rows": s["n_rows"], "angle_deg": s["angle_deg"],
|
||
}
|
||
for s in statuses if s["index"] in final
|
||
]
|
||
total_rows = sum(t["n_rows"] for t in targets)
|
||
angle_list = ", ".join(f"{t['ai'] + 1}" for t in targets)
|
||
reply = QMessageBox.question(
|
||
self, "Resume Scan",
|
||
f"Resume {path.name}?\n\n"
|
||
f"Angle(s) to (re)acquire: {angle_list}\n"
|
||
f"Total rows to acquire: {total_rows}\n\n"
|
||
"Existing data for these angle(s) (if any) will be overwritten.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
params = {
|
||
"x_start_nominal": info["x_start_nominal"], "y_start_nominal": info["y_start_nominal"],
|
||
"x_delta_nominal": info["x_delta_nominal"], "y_delta_nominal": info["y_delta_nominal"],
|
||
"num_angles": info["n_angles"],
|
||
"row_spacing": info["row_spacing"],
|
||
"laser_freq": info["laser_freq"],
|
||
"prefix": path.stem,
|
||
"save_dir": str(path.parent),
|
||
"per_angle": info["per_angle"],
|
||
}
|
||
resume_info = {
|
||
"path": path,
|
||
"targets": targets,
|
||
"samples_per_frame": info["samples_per_frame"],
|
||
}
|
||
self._launch_scan_worker(params, resume_info)
|
||
|
||
def _launch_scan_worker(self, params: dict, resume_info: dict | None = None):
|
||
# ── Launch scan worker ────────────────────────────────────────────────
|
||
self._scan_thread = QThread(self)
|
||
self._scan_worker = ScanWorker(
|
||
self._bbd_worker, self._t3r_driver, self._oscope_worker, params, resume_info
|
||
)
|
||
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._scan_worker.paused_changed.connect(self._scan_progress.on_worker_paused)
|
||
|
||
self.start_scan_btn.setEnabled(False)
|
||
self._scan_progress.reset_pause_btn()
|
||
if resume_info is None:
|
||
self._scan_progress.update_progress(0, params["per_angle"][0]["n_rows"], 0, params["num_angles"])
|
||
else:
|
||
ai0 = resume_info["targets"][0]["ai"]
|
||
self._scan_progress.update_progress(
|
||
0, params["per_angle"][ai0]["n_rows"], ai0 + 1, 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")
|
||
|
||
# Signed so the recorded/commanded angle sequence reflects the GR
|
||
# stage's actual physical rotation direction (see GR_ROTATION_SIGN).
|
||
if num_angles > 1:
|
||
angles = [GR_ROTATION_SIGN * i * 180.0 / (num_angles - 1) for i in range(num_angles)]
|
||
else:
|
||
angles = [0.0]
|
||
|
||
# Each angle only needs to physically scan the bounding box of the
|
||
# nominal (x_start, y_start, x_delta, y_delta) rectangle rotated by
|
||
# THAT angle -- not the worst case across all angles -- so the X
|
||
# extent (and therefore points/row) and the row count are computed
|
||
# per angle instead of once globally.
|
||
cx = x_start + x_delta / 2.0
|
||
cy = y_start + y_delta / 2.0
|
||
per_angle = []
|
||
for a in angles:
|
||
r = math.radians(a)
|
||
bb_w = abs(x_delta * math.cos(r)) + abs(y_delta * math.sin(r))
|
||
bb_h = abs(x_delta * math.sin(r)) + abs(y_delta * math.cos(r))
|
||
a_x_start = cx - bb_w / 2.0
|
||
a_y_start = cy - bb_h / 2.0
|
||
a_n_rows = max(1, round(bb_h / row_spacing) + 1) if bb_h > 0 else 1
|
||
a_n_frames = max(1, round(bb_w * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S))
|
||
per_angle.append({
|
||
"angle": a,
|
||
"x_start": a_x_start,
|
||
"x_delta": bb_w,
|
||
"n_frames": a_n_frames,
|
||
"n_rows": a_n_rows,
|
||
"y_positions": [a_y_start + i * row_spacing for i in range(a_n_rows)],
|
||
})
|
||
|
||
return {
|
||
"x_start_nominal": x_start, "y_start_nominal": y_start,
|
||
"x_delta_nominal": x_delta, "y_delta_nominal": y_delta,
|
||
"num_angles": num_angles,
|
||
"row_spacing": row_spacing,
|
||
"laser_freq": DEFAULT_LASER_FREQ_HZ,
|
||
"prefix": prefix,
|
||
"save_dir": save_dir,
|
||
"per_angle": per_angle,
|
||
}
|
||
|
||
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 _on_pause_scan(self, pause: bool):
|
||
if self._scan_worker:
|
||
if pause:
|
||
self._scan_worker.pause()
|
||
else:
|
||
self._scan_worker.resume()
|
||
|
||
def _disconnect_all_and_close(self):
|
||
"""Cleanly disconnect all hardware then quit."""
|
||
if self._t3r_driver.is_open:
|
||
self._t3r_driver.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()
|
||
if self._t3r_driver.is_open:
|
||
self._t3r_driver.disconnect()
|
||
for w in (self._bbd_worker, self._oscope_worker, self._helios_worker):
|
||
w.stop_worker()
|
||
for t in (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()
|