b473aac6aa
Three things were making the panel feel slow, all of them waiting rather than working. The trailing quiet window is waited out once per query, so it set the pace of the whole sweep: at 50 ms that was 400 ms of a 590 ms poll spent listening to silence. A reply streams at the baud rate — ~1 ms between bytes, no measurable gap between its lines — and the deadline restarts on every line read, so 20 ms outlasts the gap it exists for twenty times over. A tail that still arrives late is caught by _discard_input(), which is what actually protects the next query. Replayed against the rig transcript, a sweep goes from 590 ms to 356 ms. The poll interval was 1 s on top of that, so a value could be 1.6 s stale. At 0.3 s the panel comes round about every 0.65 s. And a poll held the port for its whole sweep, so a button pressed during one waited for all eight queries. _work_pending() on the worker base lets a poll drop what is left as soon as the operator queues something: a click now waits ~135 ms for the register read in progress instead of the full sweep, and the rest is picked up next time round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2166 lines
90 KiB
Python
Executable File
2166 lines
90 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 struct
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
from threading import Thread
|
||
|
||
import numpy as np
|
||
from PyQt6 import uic
|
||
from PyQt6.QtCore import QThread, QTimer, Qt, pyqtSignal
|
||
from PyQt6.QtGui import QImage, QPixmap
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
|
||
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit,
|
||
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 core.auto_align import DEFAULT_ALIGN, DEFAULT_T_AXIS
|
||
from core.config import ScanDefaults
|
||
from core.rotation import DEFAULT_ROTATION, RotationAxis
|
||
from core.scan_engine import (
|
||
ResumeState,
|
||
LASER_FREQ_HZ, SCAN_VELOCITY_MM_S,
|
||
)
|
||
from core.saw_check import middle_row_plan
|
||
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta
|
||
from core.scan_resume import is_compatible, plan_resume
|
||
from core.scope_inspect import BIAS_LABELS
|
||
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
|
||
from core.sras_format import (
|
||
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, WRITABLE_VERSIONS, SrasFile,
|
||
plan_from_header,
|
||
)
|
||
from gui.align_bridge import QtAutoAligner
|
||
from gui.jog_panel import (
|
||
BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM,
|
||
BBDJogPanel, T3RJogPanel,
|
||
)
|
||
from gui.qt_t3r import QtT3RAdapter
|
||
from gui.qt_workers import PollingQueueWorker, QueueWorker
|
||
from gui.inspect_bridge import QtAngleInspector
|
||
from gui.scan_bridge import QtScanController
|
||
from gui.widgets import mono_font
|
||
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, find_camera_bus_conflicts,
|
||
)
|
||
from t3r_control_panel import T3RControlPanel
|
||
|
||
DEFAULTS = ScanDefaults.load()
|
||
|
||
# A SAW check is written beside the scan it belongs to, under the same prefix.
|
||
# The suffix keeps it from overwriting the scan itself, which is the one file
|
||
# in the directory that cost hours to acquire.
|
||
SAW_CHECK_SUFFIX = "-sawcheck"
|
||
|
||
|
||
class DCBiasImageWidget(FigureCanvas):
|
||
"""Live 2D DC-bias image: rows × frames, matching the viewer's DC image.
|
||
|
||
The image buffer is allocated once per scan and updated in place. The
|
||
previous version rebuilt the whole array and the entire matplotlib
|
||
artist tree on every row, so the live preview got steadily slower as a
|
||
scan progressed (O(rows²) work for one scan).
|
||
"""
|
||
|
||
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._img: np.ndarray | None = None
|
||
self._im = None
|
||
self._placeholder = None
|
||
self._reset_axes()
|
||
|
||
def _reset_axes(self):
|
||
self.ax.clear()
|
||
self.ax.set_title("DC Bias Preview (ADC counts)")
|
||
self.ax.set_xlabel("Frame")
|
||
self.ax.set_ylabel("Row")
|
||
self._placeholder = self.ax.text(
|
||
0.5, 0.5, "Waiting for data…", transform=self.ax.transAxes,
|
||
ha="center", va="center", color="gray")
|
||
self._im = None
|
||
self.draw_idle()
|
||
|
||
def begin_scan(self, n_rows: int, n_frames: int):
|
||
"""Preallocate the image for a scan of this shape."""
|
||
self._img = np.full((max(1, n_rows), max(1, n_frames)), np.nan,
|
||
dtype=np.float32)
|
||
self._reset_axes()
|
||
|
||
def add_row(self, row_index: int, values):
|
||
"""Store per-frame DC means for row_index (1-based) and refresh."""
|
||
row = np.asarray(values, dtype=np.float32)
|
||
idx = row_index - 1
|
||
|
||
# Grow only if the scan turned out taller/wider than announced.
|
||
if self._img is None:
|
||
self.begin_scan(idx + 1, len(row))
|
||
if idx >= self._img.shape[0] or len(row) > self._img.shape[1]:
|
||
grown = np.full((max(idx + 1, self._img.shape[0]),
|
||
max(len(row), self._img.shape[1])),
|
||
np.nan, dtype=np.float32)
|
||
grown[:self._img.shape[0], :self._img.shape[1]] = self._img
|
||
self._img = grown
|
||
self._im = None # extent changed; rebuild the artist once
|
||
|
||
self._img[idx, :len(row)] = row
|
||
|
||
if self._im is None:
|
||
if self._placeholder is not None:
|
||
self._placeholder.remove()
|
||
self._placeholder = None
|
||
self._im = self.ax.imshow(
|
||
self._img, aspect="auto", origin="upper",
|
||
cmap="viridis", interpolation="nearest",
|
||
)
|
||
else:
|
||
self._im.set_data(self._img)
|
||
finite = self._img[np.isfinite(self._img)]
|
||
if finite.size:
|
||
self._im.set_clim(float(finite.min()), float(finite.max()))
|
||
self.draw_idle()
|
||
|
||
|
||
# ── BBD202 worker ─────────────────────────────────────────────────────────────
|
||
|
||
class BBD202Worker(PollingQueueWorker):
|
||
"""Manages ThorlabsServoDriver (MLS203-1) in a worker thread."""
|
||
position_updated = pyqtSignal(float, float) # x_mm, y_mm
|
||
homed_status = pyqtSignal(bool, bool) # x_homed, y_homed
|
||
|
||
POLL_INTERVAL_S = 0.2
|
||
|
||
def __init__(self):
|
||
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
|
||
self.controller: ThorlabsServoDriver | None = None
|
||
self.scanning_active = False # pause polling during a scan
|
||
self._jog_step = BBD_JOG_STEP_MM
|
||
self._last_pos: tuple[float, float] | None = None
|
||
self._last_homed: tuple[bool, bool] | None = None
|
||
self._handlers.update({
|
||
"connect": self._do_connect,
|
||
"disconnect": self._do_disconnect,
|
||
"jog": self._do_jog,
|
||
"set_velocity": self._do_set_velocity,
|
||
"home_all": self._do_home_all,
|
||
"enable_all": self._do_enable_all,
|
||
})
|
||
|
||
def _on_stop(self):
|
||
if self.controller:
|
||
try:
|
||
self.controller.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
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()
|
||
self.start_polling()
|
||
except Exception as e:
|
||
self.connection_failed.emit(str(e))
|
||
|
||
def _do_disconnect(self):
|
||
self.stop_polling()
|
||
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, step_mm: float | None = None):
|
||
if not self.controller:
|
||
return
|
||
dest = AXIS_X if axis == "x" else AXIS_Y
|
||
step = self._jog_step if step_mm is None else step_mm
|
||
try:
|
||
self.controller.move_axis_relative(dest, step * direction, timeout=2.0)
|
||
except TimeoutError:
|
||
pass
|
||
|
||
def _do_set_velocity(self, max_velocity: float, acceleration: float):
|
||
if not self.controller:
|
||
return
|
||
for dest in (AXIS_X, AXIS_Y):
|
||
self.controller.set_velocity_params(dest, max_velocity=max_velocity,
|
||
acceleration=acceleration)
|
||
|
||
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_once(self):
|
||
"""Emit position/homed updates, but only when they actually change."""
|
||
if self.scanning_active or not self.controller:
|
||
return
|
||
try:
|
||
pos = (self.controller.positions[0], self.controller.positions[1])
|
||
if pos != self._last_pos:
|
||
self._last_pos = pos
|
||
self.position_updated.emit(*pos)
|
||
homed = (self.controller.am_homed[0], self.controller.am_homed[1])
|
||
if homed != self._last_homed:
|
||
self._last_homed = homed
|
||
self.homed_status.emit(*homed)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Queue API ─────────────────────────────────────────────────────────────
|
||
|
||
def queue_connect(self, port: str):
|
||
self._enqueue("connect", port=port)
|
||
|
||
def queue_disconnect(self):
|
||
self._enqueue("disconnect")
|
||
|
||
def queue_jog(self, axis: str, direction: int, step_mm: float | None = None):
|
||
self._enqueue("jog", axis=axis, direction=direction, step_mm=step_mm)
|
||
|
||
def queue_set_velocity(self, max_velocity: float, acceleration: float):
|
||
self._enqueue("set_velocity", max_velocity=max_velocity,
|
||
acceleration=acceleration)
|
||
|
||
def queue_home_all(self):
|
||
self._enqueue("home_all")
|
||
|
||
def queue_enable_all(self):
|
||
self._enqueue("enable_all")
|
||
|
||
|
||
# ── Oscilloscope worker ───────────────────────────────────────────────────────
|
||
|
||
class OscopeWorker(QueueWorker):
|
||
"""Manages TektronixOscilloscopeBase in a worker thread."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.scope: TektronixOscilloscopeBase | None = None
|
||
self._handlers.update({
|
||
"connect": self._do_connect,
|
||
"disconnect": self._do_disconnect,
|
||
})
|
||
|
||
def _on_stop(self):
|
||
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()
|
||
configure_channels(self.scope)
|
||
self.is_connected = True
|
||
self.connected.emit()
|
||
except Exception as e:
|
||
self.connection_failed.emit(str(e))
|
||
|
||
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._enqueue("connect", ip=ip)
|
||
|
||
def queue_disconnect(self):
|
||
self._enqueue("disconnect")
|
||
|
||
|
||
# ── Helios laser worker ───────────────────────────────────────────────────────
|
||
|
||
class HeliosWorker(PollingQueueWorker):
|
||
"""Manages HeliosLaser in a worker thread using a command queue.
|
||
|
||
Status polling is self-rescheduling: the next poll is queued only once
|
||
the previous completes. A free-running 1 s timer used to queue a ~2 s
|
||
job, so the queue grew without bound and the panel fell further behind
|
||
the longer it stayed connected.
|
||
"""
|
||
enabled_updated = pyqtSignal(bool)
|
||
current_updated = pyqtSignal(int)
|
||
current_unknown = pyqtSignal()
|
||
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)
|
||
|
||
# A full sweep is eight queries, ~360 ms of port time. The interval is
|
||
# the gap between sweeps, so the panel refreshes about every 0.65 s.
|
||
POLL_INTERVAL_S = 0.3
|
||
|
||
def __init__(self):
|
||
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
|
||
self._laser: HeliosLaser | None = None
|
||
self._handlers.update({
|
||
"connect": self._do_connect,
|
||
"disconnect": self._do_disconnect,
|
||
"set_enable": self._do_set_enable,
|
||
"set_current": self._do_set_current,
|
||
})
|
||
|
||
def _on_stop(self):
|
||
if self._laser:
|
||
try:
|
||
self._laser.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
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.start_polling()
|
||
except Exception as e:
|
||
self._laser = None
|
||
self.connection_failed.emit(str(e))
|
||
|
||
def _do_disconnect(self):
|
||
self.stop_polling()
|
||
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
|
||
self._laser.set_laser_enable(enable)
|
||
self.enabled_updated.emit(self._laser.is_laser_enabled())
|
||
|
||
def _do_set_current(self, current_ma: int):
|
||
if not self._laser:
|
||
return
|
||
if self._laser.set_current_ma(current_ma):
|
||
self.current_updated.emit(current_ma)
|
||
return
|
||
# The manual warns the controller drops set values unintentionally,
|
||
# so the driver verifies the write. Say when it did not take: this
|
||
# used to emit the requested value regardless, and the next poll
|
||
# then quietly restored the old one.
|
||
actual = self._laser.get_current_ma()
|
||
if actual is not None:
|
||
self.current_updated.emit(actual)
|
||
self.error_occurred.emit(
|
||
f"Diode current: controller kept {actual} mA, "
|
||
f"did not accept {current_ma} mA")
|
||
|
||
def _poll_once(self):
|
||
"""Read the full status set. Individual reads are allowed to fail
|
||
(a timed-out register shouldn't suppress the rest of the panel).
|
||
|
||
Abandoned as soon as the operator queues something: the rest of the
|
||
sweep is worth less than a button that responds now, and the next
|
||
poll will pick it up.
|
||
"""
|
||
if not self._laser or not self._laser.is_connected:
|
||
return
|
||
try:
|
||
self.status_registers_updated.emit(*self._laser.get_status_registers())
|
||
except Exception:
|
||
pass
|
||
if self._work_pending():
|
||
return
|
||
try:
|
||
self.enabled_updated.emit(self._laser.is_laser_enabled())
|
||
except Exception:
|
||
pass
|
||
if self._work_pending():
|
||
return
|
||
# A failed LDS read must not leave the last good value on screen
|
||
# looking live: that is indistinguishable from a setpoint that
|
||
# refuses to move, which is the failure this panel exists to show.
|
||
try:
|
||
current = self._laser.get_current_ma()
|
||
except Exception:
|
||
current = None
|
||
if current is None:
|
||
self.current_unknown.emit()
|
||
else:
|
||
self.current_updated.emit(current)
|
||
|
||
for getter, signal in (
|
||
(self._laser.get_diode_temp_c, self.diode_temp_updated),
|
||
(self._laser.get_power_stage_temp_c, self.pstage_temp_updated),
|
||
(self._laser.get_qswitch_temp_c, self.qswitch_temp_updated),
|
||
):
|
||
if self._work_pending():
|
||
return
|
||
try:
|
||
value = getter()
|
||
if value is not None:
|
||
signal.emit(value)
|
||
except Exception:
|
||
pass
|
||
|
||
def queue_connect(self, port: str):
|
||
self._enqueue("connect", port=port)
|
||
|
||
def queue_disconnect(self):
|
||
self._enqueue("disconnect")
|
||
|
||
def queue_set_enable(self, enable: bool):
|
||
self._enqueue("set_enable", enable=enable)
|
||
|
||
def queue_set_current(self, current_ma: int):
|
||
self._enqueue("set_current", current_ma=current_ma)
|
||
|
||
|
||
# ── Camera window popup ───────────────────────────────────────────────────────
|
||
|
||
# The channel map auto-align assumes. Nothing reconfigures the scope inputs —
|
||
# CH3 is the max-velocity gate during a scan and the DC 1 monitor here — so the
|
||
# operator is asked to confirm the cabling instead of being trusted to
|
||
# remember it.
|
||
ALIGN_SCOPE_CHANNELS = ((1, "SAW"), (2, "Trigger"), (3, "DC 1"), (4, "DC 2"))
|
||
|
||
|
||
def _align_scope_prompt() -> str:
|
||
channels = "\n".join(f" CH{ch} — {role}" for ch, role in ALIGN_SCOPE_CHANNELS)
|
||
# The channels arrive on screen carrying the inspection labels, which are
|
||
# the names for the same two monitors — say so, or the operator is left
|
||
# comparing "DC 1" here against "Bias - A" on the instrument.
|
||
labels = " and ".join(BIAS_LABELS[ch] for ch in sorted(BIAS_LABELS))
|
||
return (
|
||
"Auto-align reads the DC levels off CH3 and CH4. Check the scope is "
|
||
"cabled this way before starting:\n\n"
|
||
f"{channels}\n\n"
|
||
f"The scope will label the last two {labels}.\n\n"
|
||
"The laser must be pulsing and the detection beam on the sample — the "
|
||
"procedure stops rather than servo on a scope that is not triggering.\n\n"
|
||
"Start auto-align?"
|
||
)
|
||
|
||
|
||
def _align_reference_prompt(reading) -> str:
|
||
return (
|
||
"The detector reads, where the stage is standing:\n\n"
|
||
f" DC 1 {reading.dc1_mv:8.1f} mV\n"
|
||
f" DC 2 {reading.dc2_mv:8.1f} mV\n\n"
|
||
"Is the image correct?\n\n"
|
||
f"Yes takes these as the good values and holds them to within "
|
||
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV at "
|
||
f"{DEFAULT_ALIGN.offset_mm:.2f} mm either side of here, first on X "
|
||
f"(T1) and then on Y (T0/T2 as a pair)."
|
||
)
|
||
|
||
|
||
class CameraWindow(QWidget):
|
||
"""Camera display popup — auto-connects on show, auto-disconnects on close.
|
||
|
||
Focusing and framing are done by eye, so the T3R and BBD202 jog controls
|
||
live beside the image. Both take the objects the main window already
|
||
owns; passing neither leaves the window as a plain viewer.
|
||
|
||
Auto-align lives here for the same reason: the operator judges the image
|
||
to decide the rig is on a good spot, and that judgement is the first step
|
||
of the procedure. It needs the oscilloscope as well, so the button is
|
||
only offered when all three are on hand.
|
||
"""
|
||
|
||
closed = pyqtSignal()
|
||
align_active = pyqtSignal(bool) # lock the scan panel while aligning
|
||
|
||
def __init__(self, t3r_driver: QtT3RAdapter | None = None,
|
||
bbd_worker: BBD202Worker | None = None,
|
||
oscope_worker: "OscopeWorker | None" = None,
|
||
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
|
||
|
||
self._t3r_driver = t3r_driver
|
||
self._bbd_worker = bbd_worker
|
||
self._oscope_worker = oscope_worker
|
||
self._align_thread: QThread | None = None
|
||
self._align_worker: QtAutoAligner | None = None
|
||
self._align_window: "AutoAlignWindow | None" = None
|
||
self._align_available = True
|
||
|
||
# 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)
|
||
|
||
# Every one of the three is load-bearing: the T3R moves the tilt
|
||
# platform, the BBD202 makes the 1.5 mm steps, and the scope is the
|
||
# only thing that can say whether either helped.
|
||
self.uc480_auto_align_btn.clicked.connect(self._on_auto_align)
|
||
self.uc480_auto_align_btn.setVisible(
|
||
None not in (t3r_driver, bbd_worker, oscope_worker))
|
||
|
||
self.t3r_jog_panel = self.bbd_jog_panel = None
|
||
self._build_jog_column(t3r_driver, bbd_worker)
|
||
|
||
self._update_controls()
|
||
|
||
def _build_jog_column(self, t3r_driver, bbd_worker):
|
||
"""Add the stage controls to the right of the image."""
|
||
if t3r_driver is None and bbd_worker is None:
|
||
return
|
||
column = QVBoxLayout()
|
||
column.setContentsMargins(0, 0, 0, 0)
|
||
if t3r_driver is not None:
|
||
self.t3r_jog_panel = T3RJogPanel(t3r_driver, self)
|
||
column.addWidget(self.t3r_jog_panel)
|
||
if bbd_worker is not None:
|
||
self.bbd_jog_panel = BBDJogPanel(bbd_worker, self)
|
||
column.addWidget(self.bbd_jog_panel)
|
||
column.addStretch()
|
||
self.horizontalLayout.addLayout(column)
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
self._connect_camera()
|
||
self._start_stream()
|
||
|
||
def closeEvent(self, event):
|
||
# An alignment outlives this window otherwise, and it owns the stage.
|
||
self._teardown_align()
|
||
# A jog whose button-release lands after the window is gone would
|
||
# otherwise leave an axis running.
|
||
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
|
||
if panel is not None:
|
||
panel.stop_jogs()
|
||
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)
|
||
|
||
# ── Auto-align ────────────────────────────────────────────────────────────
|
||
|
||
def _on_auto_align(self):
|
||
if self._align_thread is not None and self._align_thread.isRunning():
|
||
if self._align_window is not None:
|
||
self._align_window.raise_()
|
||
self._align_window.activateWindow()
|
||
return
|
||
|
||
problem = self._align_prerequisite_problem()
|
||
if problem:
|
||
QMessageBox.warning(self, "Cannot Auto-Align", problem)
|
||
return
|
||
|
||
if QMessageBox.question(self, "Check the Oscilloscope",
|
||
_align_scope_prompt()) \
|
||
!= QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
self._align_thread = QThread(self)
|
||
self._align_worker = QtAutoAligner(
|
||
stage=self._bbd_worker.controller,
|
||
scope=self._oscope_worker.scope,
|
||
t3r=self._t3r_driver.driver,
|
||
# Same reason the scan and the inspector do it: the procedure
|
||
# drives the stage from its own thread, and the position poll
|
||
# shares the BBD TX queue.
|
||
on_align_active=lambda active: setattr(
|
||
self._bbd_worker, "scanning_active", active),
|
||
)
|
||
self._align_worker.moveToThread(self._align_thread)
|
||
|
||
window = AutoAlignWindow(self)
|
||
self._align_window = window
|
||
worker = self._align_worker
|
||
worker.status_msg.connect(window.on_status)
|
||
worker.reading_taken.connect(window.on_reading)
|
||
worker.offset_done.connect(window.on_offset_done)
|
||
worker.axis_done.connect(window.on_axis_done)
|
||
worker.prepared.connect(self._on_align_prepared)
|
||
worker.prepare_failed.connect(self._on_align_prepare_failed)
|
||
worker.finished.connect(self._on_align_finished)
|
||
worker.failed.connect(self._on_align_failed)
|
||
worker.aborted.connect(self._on_align_aborted)
|
||
worker.stopped.connect(self._release_align_thread)
|
||
worker.error_occurred.connect(window.on_failed)
|
||
window.abort_requested.connect(worker.request_abort)
|
||
window.close_requested.connect(self._teardown_align)
|
||
|
||
self._align_thread.started.connect(worker.run)
|
||
self._set_align_ui_active(True)
|
||
window.show()
|
||
self._align_thread.start()
|
||
worker.request_prepare()
|
||
|
||
def _align_prerequisite_problem(self) -> str:
|
||
"""Why auto-align cannot start, or "" if it can."""
|
||
if self._oscope_worker is None or not self._oscope_worker.is_connected:
|
||
return ("The oscilloscope is not connected, and it is the only "
|
||
"thing that can read the DC levels. Connect it from the "
|
||
"main window and try again.")
|
||
if self._bbd_worker is None or not self._bbd_worker.is_connected:
|
||
return ("The BBD202 stage is not connected, so the 1.5 mm steps "
|
||
"cannot be made. Connect it from the main window and try "
|
||
"again.")
|
||
if self._t3r_driver is None or not self._t3r_driver.is_open:
|
||
return ("The T3R is not connected, so the T-axes cannot be moved. "
|
||
"Connect it from the T3R panel and try again.")
|
||
return ""
|
||
|
||
def _on_align_prepared(self, reading):
|
||
"""The rig is configured and standing on the point — ask the operator."""
|
||
if self._align_window is None:
|
||
return
|
||
confirmed = QMessageBox.question(
|
||
self, "Is the Image Correct?", _align_reference_prompt(reading)
|
||
) == QMessageBox.StandardButton.Yes
|
||
|
||
if not confirmed:
|
||
self._align_window.on_status(
|
||
"Cancelled: the image was not confirmed. Nothing has moved — "
|
||
"re-align by hand and start again.")
|
||
self._finish_align()
|
||
return
|
||
|
||
self._align_window.set_reference(reading)
|
||
self._align_worker.request_run()
|
||
|
||
def _on_align_prepare_failed(self, message: str):
|
||
if self._align_window is not None:
|
||
self._align_window.on_failed(message)
|
||
QMessageBox.warning(self, "Cannot Auto-Align", message)
|
||
self._finish_align()
|
||
|
||
def _on_align_finished(self, result):
|
||
if self._align_window is not None:
|
||
self._align_window.on_finished(result)
|
||
self._finish_align()
|
||
|
||
def _on_align_failed(self, message: str):
|
||
if self._align_window is not None:
|
||
self._align_window.on_failed(message)
|
||
QMessageBox.warning(self, "Auto-Align Failed", message)
|
||
self._finish_align()
|
||
|
||
def _on_align_aborted(self):
|
||
if self._align_window is not None:
|
||
self._align_window.on_aborted()
|
||
self._finish_align()
|
||
|
||
def _finish_align(self):
|
||
"""Park the rig and release the thread, leaving the summary on screen."""
|
||
if self._align_worker is not None:
|
||
self._align_worker.request_stop()
|
||
|
||
def _release_align_thread(self):
|
||
"""Worker idle — take its thread down. Safe to call more than once."""
|
||
if self._align_worker is not None:
|
||
self._align_worker.stop_worker()
|
||
if self._align_thread is not None:
|
||
self._align_thread.quit()
|
||
self._align_thread.wait(30000)
|
||
self._align_thread = None
|
||
self._align_worker = None
|
||
self._set_align_ui_active(False)
|
||
|
||
def _teardown_align(self):
|
||
"""Stop an alignment and close its window — the window or camera is
|
||
going away, so the procedure cannot be left running."""
|
||
window, self._align_window = self._align_window, None
|
||
if self._align_worker is not None:
|
||
self._align_worker.request_stop()
|
||
self._release_align_thread()
|
||
if window is not None:
|
||
window.close()
|
||
|
||
def set_align_available(self, available: bool):
|
||
"""The main window handing the rig over, or taking it back.
|
||
|
||
A scan or an angle inspection owns the same stage, so auto-align has
|
||
to be off the table while either runs.
|
||
"""
|
||
self._align_available = available
|
||
self._refresh_align_button()
|
||
|
||
def _refresh_align_button(self):
|
||
self.uc480_auto_align_btn.setEnabled(
|
||
self._align_available and self._align_thread is None)
|
||
|
||
def _set_align_ui_active(self, active: bool):
|
||
"""A running alignment owns the stage and the T-axes; nothing else
|
||
may drive them, here or in the main window."""
|
||
self._refresh_align_button()
|
||
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
|
||
if panel is not None:
|
||
panel.stop_jogs()
|
||
panel.setEnabled(not active)
|
||
self.align_active.emit(active)
|
||
|
||
|
||
class AutoAlignWindow(QWidget):
|
||
"""Live progress for one auto-align run, and the summary it ends with.
|
||
|
||
Shows the deviation from the good values rather than the raw levels: the
|
||
procedure is a null search, so how far off it is says more than what it
|
||
reads, and 5 mV out of ~400 mV does not show up in the raw number.
|
||
"""
|
||
|
||
abort_requested = pyqtSignal()
|
||
close_requested = pyqtSignal()
|
||
|
||
def __init__(self, parent: QWidget | None = None):
|
||
super().__init__(parent, Qt.WindowType.Window)
|
||
self.setWindowTitle("Auto-Align")
|
||
self.resize(560, 480)
|
||
self._reference = None
|
||
self._done = False
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
self.header_label = QLabel(
|
||
f"Stepping {DEFAULT_ALIGN.offset_mm:.2f} mm either side of this "
|
||
f"point on X (T1), then on Y (T0/T2 as a pair), re-tilting until "
|
||
f"the DC levels come back to within "
|
||
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV.\n"
|
||
f"T-axes: {DEFAULT_T_AXIS.microsteps} µsteps, "
|
||
f"{DEFAULT_T_AXIS.run_current_ma} mA.")
|
||
self.header_label.setWordWrap(True)
|
||
layout.addWidget(self.header_label)
|
||
|
||
self.reading_label = QLabel("—")
|
||
self.reading_label.setFont(mono_font(13))
|
||
self.reading_label.setStyleSheet("font-weight: bold;")
|
||
layout.addWidget(self.reading_label)
|
||
|
||
self.status_label = QLabel("Configuring the rig …")
|
||
self.status_label.setWordWrap(True)
|
||
layout.addWidget(self.status_label)
|
||
|
||
# The verdict outlives the status line, which keeps reporting the
|
||
# parking moves after the answer is known.
|
||
self.verdict_label = QLabel()
|
||
self.verdict_label.setWordWrap(True)
|
||
self.verdict_label.setVisible(False)
|
||
layout.addWidget(self.verdict_label)
|
||
|
||
self.log = QPlainTextEdit(self)
|
||
self.log.setReadOnly(True)
|
||
self.log.setFont(mono_font(11))
|
||
layout.addWidget(self.log, 1)
|
||
|
||
buttons = QHBoxLayout()
|
||
self.stop_btn = QPushButton("Stop")
|
||
self.stop_btn.setToolTip(
|
||
"Stop at the next move and put the stage back on the reference "
|
||
"point. Any tilt already applied stays applied.")
|
||
self.stop_btn.clicked.connect(self._on_stop_clicked)
|
||
self.close_btn = QPushButton("Close")
|
||
self.close_btn.clicked.connect(self.close)
|
||
self.close_btn.setEnabled(False)
|
||
buttons.addWidget(self.stop_btn)
|
||
buttons.addWidget(self.close_btn)
|
||
layout.addLayout(buttons)
|
||
|
||
# ── Worker → window ───────────────────────────────────────────────────────
|
||
|
||
def set_reference(self, reading):
|
||
self._reference = reading
|
||
self._append(f"Reference: {reading.describe()}")
|
||
|
||
def on_status(self, msg: str):
|
||
self.status_label.setText(msg)
|
||
|
||
def on_reading(self, reading):
|
||
if self._reference is None:
|
||
self.reading_label.setText(reading.describe())
|
||
return
|
||
d1, d2 = reading.error_vs(self._reference)
|
||
self.reading_label.setText(
|
||
f"DC1 {reading.dc1_mv:8.1f} mV ({d1:+6.1f}) "
|
||
f"DC2 {reading.dc2_mv:8.1f} mV ({d2:+6.1f})")
|
||
|
||
def on_offset_done(self, result):
|
||
self._append(f" {result.describe()}")
|
||
|
||
def on_axis_done(self, result):
|
||
self._append(result.describe())
|
||
|
||
def on_finished(self, result):
|
||
# The per-axis lines are already in the log, put there as they
|
||
# happened; only the closing verdict is new.
|
||
self._append("")
|
||
self._append(result.verdict())
|
||
self._set_verdict(result.verdict(), ok=result.ok)
|
||
|
||
def on_failed(self, message: str):
|
||
self._append(f"FAILED: {message}")
|
||
self._set_verdict(message, ok=False)
|
||
|
||
def on_aborted(self):
|
||
message = ("Stopped by the operator. Any tilt already applied stays "
|
||
"applied.")
|
||
self._append(message)
|
||
self._set_verdict(message, ok=False)
|
||
|
||
# ── Window → worker ───────────────────────────────────────────────────────
|
||
|
||
def _on_stop_clicked(self):
|
||
self.stop_btn.setEnabled(False)
|
||
self.on_status("Stopping at the next move …")
|
||
self.abort_requested.emit()
|
||
|
||
def _set_verdict(self, text: str, ok: bool):
|
||
self.verdict_label.setText(text)
|
||
self.verdict_label.setStyleSheet(
|
||
f"font-weight: bold; color: {'green' if ok else '#b8860b'};")
|
||
self.verdict_label.setVisible(True)
|
||
self._done = True
|
||
self.stop_btn.setEnabled(False)
|
||
self.close_btn.setEnabled(True)
|
||
|
||
def _append(self, line: str):
|
||
self.log.appendPlainText(line)
|
||
|
||
def closeEvent(self, event):
|
||
# Closing part-way through is a stop: the procedure owns the stage,
|
||
# and nothing else can call it off once this window is gone.
|
||
if not self._done:
|
||
self.abort_requested.emit()
|
||
self.close_requested.emit()
|
||
super().closeEvent(event)
|
||
|
||
|
||
# ── 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
|
||
# Polling is driven by the worker itself (self-rescheduling), so
|
||
# there is no timer here to outpace the device.
|
||
self._seed_current_spin = False
|
||
self._wire_signals()
|
||
self._update_controls(False)
|
||
self._set_emission_indicator(False)
|
||
|
||
def closeEvent(self, event):
|
||
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.current_unknown.connect(
|
||
lambda: self.helios_current_readback_label.setText("laser: ? mA")
|
||
)
|
||
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()
|
||
DEFAULTS.helios_port = port
|
||
DEFAULTS.save()
|
||
self._worker.queue_connect(port)
|
||
else:
|
||
self._worker.queue_disconnect()
|
||
|
||
def _on_connected(self):
|
||
# Take the setpoint from the laser once, on connect.
|
||
self._seed_current_spin = True
|
||
self.helios_connect_toggle.setEnabled(True)
|
||
self.helios_connect_toggle.setText("Disconnect")
|
||
self.helios_status_label.setText("Connected")
|
||
self._update_controls(True)
|
||
|
||
def _on_disconnected(self):
|
||
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):
|
||
"""Show what the laser reports; leave the spin box to the operator.
|
||
|
||
The 1 Hz poll used to write its reading straight into the spin box,
|
||
so a number typed there was overwritten within a second — the
|
||
setpoint appeared to snap back to the controller's value before it
|
||
could be sent. The spin box is now seeded once per connection and
|
||
is the operator's alone after that; the label is the read-back.
|
||
"""
|
||
self.helios_current_readback_label.setText(f"laser: {current_ma} mA")
|
||
if self._seed_current_spin:
|
||
self._seed_current_spin = False
|
||
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._seed_current_spin = False
|
||
self.helios_current_readback_label.setText("laser: --- mA")
|
||
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, 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.Unchecked if s.complete else Qt.CheckState.Checked
|
||
)
|
||
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._eta = EtaEstimator()
|
||
|
||
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._eta.row_started()
|
||
|
||
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._eta.reset()
|
||
self.current_scan_progbar.setMaximum(n_rows)
|
||
self.current_scan_progbar.setValue(0)
|
||
self.current_scan_progbar.setFormat("Row %v/%m")
|
||
else:
|
||
self._eta.row_finished(angle_idx)
|
||
self.current_scan_progbar.setMaximum(n_rows)
|
||
self.current_scan_progbar.setValue(row)
|
||
|
||
rows_left = n_rows - row
|
||
eta_secs = self._eta.eta_secs(rows_left)
|
||
if eta_secs is not None:
|
||
self.current_scan_progbar.setFormat(
|
||
f"Row %v/%m — ETA: {format_eta(eta_secs)}")
|
||
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)
|
||
|
||
def begin_scan(self, n_rows: int, n_frames: int):
|
||
"""Size the DC preview for the scan about to run."""
|
||
self.bias_image_widget.begin_scan(n_rows, n_frames)
|
||
|
||
|
||
# ── Scan worker ───────────────────────────────────────────────────────────────
|
||
|
||
# ── Main window ───────────────────────────────────────────────────────────────
|
||
|
||
class AngleInspectWindow(QWidget):
|
||
"""Click through a plan's angles, parking the rig at a point in each.
|
||
|
||
Deliberately shows no waveform. The operator reads the SAW response and
|
||
the bias levels off the oscilloscope itself — this window only says where
|
||
the rig is and lets them move it somewhere else.
|
||
"""
|
||
|
||
goto_requested = pyqtSignal(int)
|
||
new_point_requested = pyqtSignal()
|
||
stop_requested = pyqtSignal()
|
||
|
||
def __init__(self, angle_labels: list[str], parent: QWidget | None = None):
|
||
super().__init__(parent, Qt.WindowType.Window)
|
||
self.setWindowTitle("Inspect Angles")
|
||
self.resize(420, 460)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.addWidget(QLabel(
|
||
"Select an angle to rotate to it and park at a random point in "
|
||
"its scan area.\nRead the SAW response on the oscilloscope."
|
||
))
|
||
|
||
self.angle_list = QListWidget(self)
|
||
for i, label in enumerate(angle_labels):
|
||
item = QListWidgetItem(label)
|
||
item.setData(Qt.ItemDataRole.UserRole, i)
|
||
self.angle_list.addItem(item)
|
||
self.angle_list.setCurrentRow(0)
|
||
# currentRowChanged would also fire when the code syncs the highlight
|
||
# back after a move, re-triggering the move it is reporting.
|
||
self.angle_list.itemClicked.connect(self._on_item_clicked)
|
||
layout.addWidget(self.angle_list)
|
||
|
||
nav_row = QHBoxLayout()
|
||
self.prev_btn = QPushButton("◀ Previous")
|
||
self.next_btn = QPushButton("Next ▶")
|
||
self.new_point_btn = QPushButton("New Point")
|
||
self.new_point_btn.setToolTip(
|
||
"Pick another random point at this angle without rotating — a bad "
|
||
"spot on the sample looks the same as a bad angle until you move."
|
||
)
|
||
self.prev_btn.clicked.connect(self._on_prev)
|
||
self.next_btn.clicked.connect(self._on_next)
|
||
self.new_point_btn.clicked.connect(self.new_point_requested.emit)
|
||
nav_row.addWidget(self.prev_btn)
|
||
nav_row.addWidget(self.next_btn)
|
||
nav_row.addWidget(self.new_point_btn)
|
||
layout.addLayout(nav_row)
|
||
|
||
self.point_label = QLabel("—")
|
||
self.point_label.setStyleSheet("font-weight: bold;")
|
||
layout.addWidget(self.point_label)
|
||
|
||
self.status_label = QLabel("Starting …")
|
||
self.status_label.setWordWrap(True)
|
||
layout.addWidget(self.status_label)
|
||
|
||
self.close_btn = QPushButton("Close")
|
||
self.close_btn.clicked.connect(self.close)
|
||
layout.addWidget(self.close_btn)
|
||
|
||
self._n_angles = len(angle_labels)
|
||
self._angle_idx = 0
|
||
self.set_busy(True)
|
||
|
||
# ── Worker → window ───────────────────────────────────────────────────────
|
||
|
||
def set_busy(self, busy: bool):
|
||
"""Lock navigation while the stage is moving; the rig is not re-entrant."""
|
||
for w in (self.angle_list, self.prev_btn, self.next_btn,
|
||
self.new_point_btn):
|
||
w.setEnabled(not busy)
|
||
|
||
def on_status(self, msg: str):
|
||
self.status_label.setText(msg)
|
||
|
||
def on_point(self, point):
|
||
self._angle_idx = point.angle_idx
|
||
self.angle_list.setCurrentRow(point.angle_idx)
|
||
self.point_label.setText(point.describe())
|
||
|
||
# ── Window → worker ───────────────────────────────────────────────────────
|
||
|
||
def _on_item_clicked(self, item):
|
||
self.goto_requested.emit(item.data(Qt.ItemDataRole.UserRole))
|
||
|
||
def _on_prev(self):
|
||
self.goto_requested.emit((self._angle_idx - 1) % self._n_angles)
|
||
|
||
def _on_next(self):
|
||
self.goto_requested.emit((self._angle_idx + 1) % self._n_angles)
|
||
|
||
def closeEvent(self, event):
|
||
self.stop_requested.emit()
|
||
super().closeEvent(event)
|
||
|
||
|
||
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 thread internally) ──────────────
|
||
# QtT3RAdapter re-emits the driver's thread-side callbacks as queued
|
||
# Qt signals so widget slots run on the GUI thread.
|
||
self._t3r_driver = QtT3RAdapter(parent=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._t3r_driver, self._bbd_worker,
|
||
self._oscope_worker)
|
||
self._helios_win = HeliosWindow(self._helios_worker)
|
||
self._scan_progress = ScanProgressWindow()
|
||
|
||
self._scan_worker: QtScanController | None = None
|
||
# A SAW check runs through the same worker as a scan; this says which,
|
||
# since the two finish very differently (a check hands the operator a
|
||
# file to look at; a scan shuts the rig down).
|
||
self._scan_is_saw_check = False
|
||
self._saw_check_path: Path | None = None
|
||
self._inspect_thread: QThread | None = None
|
||
self._inspect_worker: QtAngleInspector | None = None
|
||
self._inspect_window: AngleInspectWindow | 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(DEFAULTS.t3r_port)
|
||
self.bbd202_comport_edit.setText(DEFAULTS.bbd_port)
|
||
self.oscope_ip_edit.setText(DEFAULTS.oscope_ip)
|
||
self._helios_win.helios_port_edit.setText(DEFAULTS.helios_port)
|
||
self.lineEdit_10.setText(str(BBD_JOG_STEP_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(DEFAULTS.save_dir)
|
||
self.burst_mode_check.setChecked(DEFAULTS.burst_mode)
|
||
self.strict_rows_check.setChecked(DEFAULTS.strict_rows)
|
||
|
||
# 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)
|
||
self.burst_mode_check.toggled.connect(self._persist_defaults)
|
||
self.strict_rows_check.toggled.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")
|
||
)
|
||
self._camera_win.align_active.connect(self._on_camera_align_active)
|
||
|
||
# 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.saw_check_btn.clicked.connect(self._on_saw_check)
|
||
self.inspect_angles_btn.clicked.connect(self._on_inspect_angles)
|
||
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:
|
||
step = float(self.lineEdit_10.text())
|
||
except ValueError:
|
||
step = BBD_JOG_STEP_MM
|
||
# The step rides along with the command: writing it onto the worker
|
||
# from here would be a cross-thread poke at a field the worker reads.
|
||
self._bbd_worker.queue_jog(axis, direction, step_mm=step)
|
||
|
||
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):
|
||
DEFAULTS.t3r_port = self.t3r_comport_edit.text().strip()
|
||
DEFAULTS.bbd_port = self.bbd202_comport_edit.text().strip()
|
||
DEFAULTS.oscope_ip = self.oscope_ip_edit.text().strip()
|
||
DEFAULTS.save_dir = self.scan_save_dir_edit.text().strip()
|
||
DEFAULTS.burst_mode = self.burst_mode_check.isChecked()
|
||
DEFAULTS.strict_rows = self.strict_rows_check.isChecked()
|
||
DEFAULTS.save()
|
||
|
||
# ── 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 _set_scan_buttons_enabled(self, enabled: bool):
|
||
"""Both entry points drive the same rig, so they lock and unlock together."""
|
||
self.start_scan_btn.setEnabled(enabled)
|
||
self.saw_check_btn.setEnabled(enabled)
|
||
# Auto-align lives in the camera window but drives this same stage.
|
||
self._camera_win.set_align_available(enabled)
|
||
|
||
def _on_camera_align_active(self, active: bool):
|
||
"""An alignment started or finished in the camera window."""
|
||
self._set_scan_buttons_enabled(not active)
|
||
self.inspect_angles_btn.setEnabled(not active)
|
||
|
||
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_saw_check(self):
|
||
"""Acquire the middle row of the current ROI at every angle.
|
||
|
||
Same engine, same hardware sequence, same file format as a scan — the
|
||
plan is just reduced to one row per angle and the result is tagged v10
|
||
so the viewer knows it is a check rather than a scan cut short.
|
||
"""
|
||
if self._scan_thread is not None and self._scan_thread.isRunning():
|
||
QMessageBox.warning(
|
||
self, "Scan In Progress",
|
||
"A scan is running — abort it before starting a SAW check."
|
||
)
|
||
return
|
||
try:
|
||
plan, prefix, save_dir = self._build_scan_plan()
|
||
check_plan = middle_row_plan(plan) # ScanGeometryError is a ValueError
|
||
except ValueError as e:
|
||
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
|
||
return
|
||
|
||
check_prefix = f"{prefix}{SAW_CHECK_SUFFIX}"
|
||
out_path = Path(save_dir) / f"{check_prefix}.sras"
|
||
rows = ", ".join(f"{pa.angle_deg:.1f}°: Y={pa.y_positions[0]:.3f} mm"
|
||
for pa in check_plan.per_angle)
|
||
overwrite = ("\n\nThis will overwrite the existing file."
|
||
if out_path.exists() else "")
|
||
reply = QMessageBox.question(
|
||
self, "SAW Quality Check",
|
||
f"Acquire the middle row of the ROI at {check_plan.n_angles} angle(s)?\n\n"
|
||
f"{rows}\n\n"
|
||
"Each angle starts with its own background capture, so you will be "
|
||
"asked to switch the Genesis laser off and back on at every angle."
|
||
f"\n\nSave → {out_path.name}{overwrite}",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
self._saw_check_path = out_path
|
||
# Burst mode is deliberately not offered here: one row per angle means
|
||
# every burst would be a single row, so it buys nothing and still pays
|
||
# for the gate preflight.
|
||
self._launch_scan_worker(check_plan, check_prefix, save_dir, saw_check=True)
|
||
|
||
def _on_saw_check_complete(self):
|
||
"""A check is a thing to look at, not a run to shut down after."""
|
||
path = self._saw_check_path
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Icon.Information)
|
||
box.setWindowTitle("SAW Check Complete")
|
||
box.setText(
|
||
f"Middle-row SAW check written to:\n{path}\n\n"
|
||
"Open it in the SAW Check Viewer to compare each angle's "
|
||
"frequency and judge the alignment."
|
||
)
|
||
open_btn = box.addButton("Open Viewer", QMessageBox.ButtonRole.AcceptRole)
|
||
box.addButton(QMessageBox.StandardButton.Close)
|
||
box.exec()
|
||
if box.clickedButton() is open_btn:
|
||
self._launch_saw_check_viewer(path)
|
||
|
||
def _launch_saw_check_viewer(self, path: Path):
|
||
"""Open the viewer as its own process.
|
||
|
||
Deliberately not in-process: the acquisition app owns the hardware and
|
||
must stay responsive, and the viewer is a separate entry point that
|
||
outlives any one scan session.
|
||
"""
|
||
try:
|
||
subprocess.Popen([sys.executable,
|
||
str(ROOT / "saw_check_viewer.py"), str(path)])
|
||
except OSError as e:
|
||
QMessageBox.warning(
|
||
self, "Could Not Open Viewer",
|
||
f"Could not start the SAW Check Viewer:\n\n{e}\n\n"
|
||
f"Run it manually: python saw_check_viewer.py {path}"
|
||
)
|
||
|
||
def _on_inspect_angles(self):
|
||
"""Open the pre-scan angle inspector for the plan currently entered."""
|
||
if self._scan_thread is not None and self._scan_thread.isRunning():
|
||
QMessageBox.warning(
|
||
self, "Scan In Progress",
|
||
"A scan is running — abort it before inspecting angles."
|
||
)
|
||
return
|
||
if self._inspect_thread is not None and self._inspect_thread.isRunning():
|
||
self._inspect_window.raise_()
|
||
self._inspect_window.activateWindow()
|
||
return
|
||
|
||
try:
|
||
plan, _, _ = self._build_scan_plan()
|
||
except ValueError as e:
|
||
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
|
||
return
|
||
|
||
rotator = RotationAxis(self._t3r_driver.driver, DEFAULT_ROTATION)
|
||
self._inspect_thread = QThread(self)
|
||
self._inspect_worker = QtAngleInspector(
|
||
stage=self._bbd_worker.controller,
|
||
scope=self._oscope_worker.scope,
|
||
rotator=rotator,
|
||
plan=plan,
|
||
# Same reason the scan does it: the inspector drives the stage from
|
||
# its own thread, and the position poll shares the BBD TX queue.
|
||
on_inspect_active=lambda active: setattr(
|
||
self._bbd_worker, "scanning_active", active),
|
||
)
|
||
self._inspect_worker.moveToThread(self._inspect_thread)
|
||
|
||
window = AngleInspectWindow(self._inspect_worker.angle_labels(), self)
|
||
self._inspect_window = window
|
||
|
||
window.goto_requested.connect(self._inspect_worker.request_goto)
|
||
window.new_point_requested.connect(self._inspect_worker.request_new_point)
|
||
window.stop_requested.connect(self._on_inspect_window_closed)
|
||
|
||
self._inspect_worker.status_msg.connect(window.on_status)
|
||
self._inspect_worker.point_changed.connect(window.on_point)
|
||
self._inspect_worker.busy_changed.connect(window.set_busy)
|
||
self._inspect_worker.start_failed.connect(self._on_inspect_failed)
|
||
self._inspect_worker.error_occurred.connect(
|
||
lambda m: QMessageBox.warning(self, "Inspection Error", m))
|
||
|
||
self._inspect_thread.started.connect(self._inspect_worker.run)
|
||
self._set_scan_buttons_enabled(False)
|
||
self.inspect_angles_btn.setEnabled(False)
|
||
window.show()
|
||
self._inspect_thread.start()
|
||
self._inspect_worker.request_start()
|
||
|
||
def _on_inspect_failed(self, message: str):
|
||
QMessageBox.warning(self, "Cannot Inspect Angles", message)
|
||
if self._inspect_window is not None:
|
||
self._inspect_window.close()
|
||
|
||
def _on_inspect_window_closed(self):
|
||
"""Tear the worker down and hand the hardware back to the scan panel."""
|
||
if self._inspect_worker is not None:
|
||
self._inspect_worker.request_stop()
|
||
self._inspect_worker.stop_worker()
|
||
if self._inspect_thread is not None:
|
||
self._inspect_thread.quit()
|
||
self._inspect_thread.wait(10000)
|
||
self._inspect_thread = None
|
||
self._inspect_worker = None
|
||
self._inspect_window = None
|
||
self._set_scan_buttons_enabled(True)
|
||
self.inspect_angles_btn.setEnabled(True)
|
||
|
||
def _on_start_scan(self):
|
||
try:
|
||
plan, prefix, save_dir = self._build_scan_plan()
|
||
except ValueError as e:
|
||
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
|
||
return
|
||
self._launch_scan_worker(plan, prefix, save_dir)
|
||
|
||
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 DEFAULTS.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:
|
||
sras = SrasFile(path)
|
||
statuses = sras.angle_status()
|
||
except (ValueError, struct.error, OSError) as e:
|
||
QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}")
|
||
return
|
||
|
||
if sras.version not in WRITABLE_VERSIONS:
|
||
QMessageBox.warning(
|
||
self, "Cannot Resume Scan",
|
||
f"{path.name} is a v{sras.version} file, written before each "
|
||
"angle carried its own background waveform. Every angle this "
|
||
"app acquires now writes a background block that the older "
|
||
"layout has no room for, so resuming would shift the file's "
|
||
"data. Start a new scan instead — the old file still opens in "
|
||
"the viewer."
|
||
)
|
||
return
|
||
|
||
if not is_compatible(sras, velocity=SCAN_VELOCITY_MM_S,
|
||
laser_freq=LASER_FREQ_HZ, sample_rate=SAMPLE_RATE_HZ,
|
||
n_channels=len(SCAN_CHANNELS)):
|
||
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
|
||
|
||
resume_plan = plan_resume(statuses, selected)
|
||
if resume_plan.auto_added:
|
||
QMessageBox.information(
|
||
self, "Additional Angles Required",
|
||
"The scan file has no data yet past angle "
|
||
f"{resume_plan.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 resume_plan.auto_added]}"
|
||
)
|
||
|
||
angle_list = ", ".join(str(t.angle_idx + 1) for t in resume_plan.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: {resume_plan.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
|
||
|
||
self._launch_scan_worker(plan_from_header(sras), path.stem,
|
||
str(path.parent), resume_plan.to_state(sras))
|
||
|
||
def _launch_scan_worker(self, plan: ScanPlan, prefix: str, save_dir: str,
|
||
resume: ResumeState | None = None,
|
||
saw_check: bool = False):
|
||
rotator = RotationAxis(self._t3r_driver.driver, DEFAULT_ROTATION)
|
||
self._scan_is_saw_check = saw_check
|
||
|
||
self._scan_thread = QThread(self)
|
||
self._scan_worker = QtScanController(
|
||
stage=self._bbd_worker.controller,
|
||
scope=self._oscope_worker.scope,
|
||
rotator=rotator,
|
||
plan=plan,
|
||
out_path=Path(save_dir) / f"{prefix}.sras",
|
||
resume=resume,
|
||
# Suppress BBD position polling for the duration of the scan —
|
||
# a worker concern, not the engine's.
|
||
on_scan_active=lambda active: setattr(
|
||
self._bbd_worker, "scanning_active", active),
|
||
burst_mode=self.burst_mode_check.isChecked() and not saw_check,
|
||
strict_rows=self.strict_rows_check.isChecked(),
|
||
file_version=VERSION_SAW_CHECK if saw_check else VERSION,
|
||
)
|
||
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._set_scan_buttons_enabled(False)
|
||
self._scan_progress.reset_pause_btn()
|
||
ai0 = 0 if resume is None else resume.targets[0].angle_idx
|
||
self._scan_progress.update_progress(
|
||
0, plan.per_angle[ai0].n_rows,
|
||
0 if resume is None else ai0 + 1, plan.n_angles
|
||
)
|
||
self._scan_progress.begin_scan(plan.per_angle[ai0].n_rows,
|
||
plan.per_angle[ai0].n_frames)
|
||
self._scan_progress.show()
|
||
self._scan_thread.start()
|
||
|
||
def _build_scan_plan(self) -> tuple[ScanPlan, str, str]:
|
||
def _f(w, label):
|
||
try:
|
||
return float(w.text())
|
||
except ValueError:
|
||
raise ValueError(f"'{label}' is not a valid number: {w.text()!r}") from None
|
||
def _i(w, label):
|
||
try:
|
||
return int(w.text())
|
||
except ValueError:
|
||
raise ValueError(f"'{label}' is not a valid integer: {w.text()!r}") from None
|
||
|
||
plan = build_plan(
|
||
_f(self.x_start_edit, "XS"), _f(self.y_start_edit, "YS"),
|
||
_f(self.x_delta_edit, "XD"), _f(self.y_delta_edit, "YD"),
|
||
_i(self.num_angles_edit, "NumAngles"),
|
||
_f(self.row_spacing_edit, "RowSpacing"),
|
||
laser_freq_hz=LASER_FREQ_HZ, velocity_mm_s=SCAN_VELOCITY_MM_S,
|
||
rotation_sign=DEFAULT_ROTATION.rotation_sign,
|
||
)
|
||
prefix = self.scan_prefix_edit.text().strip() or "scan"
|
||
save_dir = self.scan_save_dir_edit.text().strip() or DEFAULTS.save_dir
|
||
return plan, prefix, save_dir
|
||
|
||
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._set_scan_buttons_enabled(True)
|
||
if self._scan_is_saw_check:
|
||
self._scan_is_saw_check = False
|
||
self._on_saw_check_complete()
|
||
return
|
||
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._set_scan_buttons_enabled(True)
|
||
self._scan_is_saw_check = False
|
||
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.close()
|
||
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.close()
|
||
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()
|