Files
scanengine-3/gui/widgets.py
T
Thomas Ales 6e8c1cb7a2 Auto-align: level the sample on the DC bias levels from the camera window
The operator frames a good spot, confirms the two DC levels the detector
reads there, and the rig then measures its own tilt: step 1.5 mm either side
on X and then on Y, and tilt the platform until those levels come back.  The
correction that fixes an offset point is the correction that levels the whole
travel — height error and tilt effect are both proportional to the offset —
so the procedure ends by applying it and leaving it applied.

Both directions are measured from the same starting tilt and averaged, which
makes their disagreement a flatness read-out rather than something averaged
away silently.

core/auto_align.py holds the geometry and the search, Qt-free.  The three
T-axes' azimuths are the whole geometry: T1 lies along +X so it alone tilts
along X, and T0/T2 move as an equal-and-opposite pair to tilt along Y without
touching X (tilt_response derives that, and the tests pin it — an axis map
that drifts would still converge, on the wrong axis).  The search is a secant
null on the split-detector difference: probe once to learn what a microstep
is worth, sign included, then step at the null.  It refuses to servo on a
scope that has not re-triggered, escalates a probe that reads as no response
before calling an axis dead, and stops at a per-axis travel limit.

gui/align_bridge.py runs it on a worker thread; stopping is a threading.Event
rather than a queued command, because the worker is inside a long handler for
the whole run.  The camera window carries the button and the progress window,
and locks the scan panel and the jog pads while a run owns the stage.

Adds immediate MEAN measurements and an acquisition count to the scope
driver, and read_bias_mv to core/scope_inspect — the one scalar the
inspection state was missing.

KNOWN_ISSUES.md records what only the rig can settle: the probe step, the
travel limit, the hold current, and whether the piston the X phase applies
alongside its tilt matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:00:36 -05:00

193 lines
6.5 KiB
Python

"""Widgets shared by the main app and the per-device test benches.
Each of these was hand-rolled several times across the apps, with slightly
different behaviour every time (only one log console bounded its buffer,
only one port picker sorted by device type).
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QComboBox, QGroupBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget,
)
from hardware.serial_util import scored_ports
def mono_font(size: int = 11) -> QFont:
"""Monospace font for numeric read-outs, so columns of digits line up."""
font = QFont("Menlo")
font.setStyleHint(QFont.StyleHint.Monospace)
font.setPointSize(size)
return font
def set_toggle(btn, checked: bool, text: str, enabled: bool = True):
"""Update a checkable button without re-triggering its toggled signal."""
btn.blockSignals(True)
btn.setChecked(checked)
btn.setText(text)
btn.setEnabled(enabled)
btn.blockSignals(False)
class PortSelector(QWidget):
"""Serial port combo + refresh button, likeliest device first."""
def __init__(self, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(0, 0, 0, 0)
self.combo = QComboBox()
self.combo.setMinimumWidth(220)
refresh = QPushButton("⟳")
refresh.setFixedWidth(30)
refresh.setToolTip("Rescan serial ports")
refresh.clicked.connect(self.refresh)
row.addWidget(self.combo, stretch=1)
row.addWidget(refresh)
self.refresh()
def refresh(self):
"""Repopulate the list, preserving the current selection if present."""
current = self.current_port()
self.combo.clear()
for device, label in scored_ports():
self.combo.addItem(label, device)
if self.combo.count() == 0:
self.combo.addItem("(no serial ports found)", None)
elif current:
idx = self.combo.findData(current)
if idx >= 0:
self.combo.setCurrentIndex(idx)
def current_port(self) -> str | None:
return self.combo.currentData()
def set_port(self, device: str):
idx = self.combo.findData(device)
if idx >= 0:
self.combo.setCurrentIndex(idx)
class ConnectionBar(QGroupBox):
"""Port picker + connect toggle + status label.
Emits connect_requested(port) / disconnect_requested(); the owner drives
the state back through on_connected/on_disconnected/on_failed so the
button can never disagree with the hardware.
"""
connect_requested = pyqtSignal(str)
disconnect_requested = pyqtSignal()
def __init__(self, title: str = "Connection", parent=None):
super().__init__(title, parent)
layout = QVBoxLayout(self)
self.port_selector = PortSelector()
layout.addWidget(self.port_selector)
row = QHBoxLayout()
self.toggle = QPushButton("Connect")
self.toggle.setCheckable(True)
self.toggle.toggled.connect(self._on_toggled)
self.status = QLabel("Disconnected")
self.status.setStyleSheet("font-weight: bold;")
row.addWidget(self.toggle)
row.addWidget(self.status, stretch=1)
layout.addLayout(row)
def _on_toggled(self, checked: bool):
if checked:
port = self.port_selector.current_port()
if not port:
set_toggle(self.toggle, False, "Connect")
self.status.setText("No port selected")
return
self.toggle.setText("Connecting…")
self.toggle.setEnabled(False)
self.connect_requested.emit(port)
else:
self.disconnect_requested.emit()
def on_connected(self, detail: str = "Connected"):
set_toggle(self.toggle, True, "Disconnect")
self.status.setText(detail)
self.status.setStyleSheet("font-weight: bold; color: green;")
def on_disconnected(self, detail: str = "Disconnected"):
set_toggle(self.toggle, False, "Connect")
self.status.setText(detail)
self.status.setStyleSheet("font-weight: bold;")
def on_failed(self, message: str):
set_toggle(self.toggle, False, "Connect")
self.status.setText(f"Failed: {message}")
self.status.setStyleSheet("font-weight: bold; color: red;")
class LogConsole(QWidget):
"""Bounded, monospace, auto-scrolling log view with a Clear button.
The block cap is the point: unbounded QTextEdit logs grew for the whole
session in every app that hand-rolled one.
"""
KINDS = {"tx": "→", "rx": "←", "info": "●", "err": "!"}
def __init__(self, max_blocks: int = 2000, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = QPlainTextEdit()
self.view.setReadOnly(True)
self.view.setMaximumBlockCount(max_blocks)
self.view.setFont(mono_font())
layout.addWidget(self.view)
row = QHBoxLayout()
row.addStretch(1)
clear = QPushButton("Clear")
clear.clicked.connect(self.view.clear)
row.addWidget(clear)
layout.addLayout(row)
def log(self, message: str, kind: str = "info"):
self.view.appendPlainText(f"{self.KINDS.get(kind, '●')} {message}")
self.view.verticalScrollBar().setValue(
self.view.verticalScrollBar().maximum())
class StatusGrid(QWidget):
"""Label/value rows with consistent ok/warn/error colouring."""
_COLORS = {"ok": "green", "warn": "#b8860b", "error": "red", "": ""}
def __init__(self, fields: list[str], parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._values: dict[str, QLabel] = {}
for name in fields:
row = QHBoxLayout()
label = QLabel(f"{name}:")
value = QLabel("—")
value.setAlignment(Qt.AlignmentFlag.AlignRight
| Qt.AlignmentFlag.AlignVCenter)
row.addWidget(label)
row.addWidget(value, stretch=1)
layout.addLayout(row)
self._values[name] = value
def set(self, name: str, text: str, state: str = ""):
label = self._values.get(name)
if label is None:
return
label.setText(text)
color = self._COLORS.get(state, "")
label.setStyleSheet(f"color: {color};" if color else "")