6e8c1cb7a2
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>
193 lines
6.5 KiB
Python
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 "")
|