Pre-scan angle inspection: park the rig per angle, read the response on the scope
A 9-angle scan takes hours, and an angle that responds poorly still produces rows that look structurally fine in the file — the SAW packet is just not there. This lets the operator walk the angles first, parking the rig at a random point in each, and judge the response before committing to the run. Nothing reads the scope. The operator inspects the instrument directly, so there is no transfer path, no plotting, and no waveform crossing the module boundary — test_inspection_never_reads_a_waveform_back pins that, since it is the kind of premise a later change erodes without noticing. core/scope_inspect.py — the scope state worth looking at, which is not the scan's state: - plain rising-edge trigger on CH2 at 2.0 V, not the scan's logic AND of the laser pulse and the stage gate, so a stationary stage still triggers - FastFrame off, SAMPLE (no averaging) — a weak or intermittent response is exactly what is being looked for, and averaging would hide it - free-running (STOPAfter RUNSTop + STATE RUN) so the trace keeps updating while the operator looks at it - CH1 keeps the acquisition front-end verbatim, so what is on screen is what a scan would record - CH3/CH4 become bias monitors sharing one scale and position, since the comparison is by eye and only works if a division means the same on each. 100 mV/div with ground 3.5 divisions below centre puts 0–700 mV on screen with headroom on an 8- or 10-division graticule (the signal never goes negative, hence moving the trace down). core/angle_inspect.py — AngleInspector, headless and Qt-free like ScanEngine. Points are drawn from the angle's own bounding box: Y from its actual row positions and X uniformly across its data window, so the point is somewhere the scan would really sample rather than merely inside the box. New Point re-rolls without rotating, which is what separates a bad spot on the sample from a bad angle. The stage gate is held off throughout, and the rotator goes home on stop. gui/inspect_bridge.py — QtAngleInspector on the existing QueueWorker base. Inspection is click-driven rather than one long run, so the worker blocks on its queue between commands and an open window costs nothing. BBD position polling is suppressed while inspecting, for the same reason the scan does it: the shared TX queue. sc3_aui_app.py — AngleInspectWindow (angle list, prev/next, New Point) driven off the plan currently entered in the scan panel, so it inspects exactly the scan about to be run. Navigation locks while the stage moves. The list syncs via itemClicked rather than currentRowChanged, so echoing the worker's position back does not re-trigger the move it is reporting. README picks up the new modules, and scope_burst.py which the previous merge left out of the structure listing. 114 tests passing, ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+176
@@ -38,6 +38,7 @@ from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
|
||||
from core.sras_format import SCAN_CHANNELS, SrasFile, plan_from_header
|
||||
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 hardware.helios_laser import HeliosLaser
|
||||
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
|
||||
@@ -844,6 +845,104 @@ class ScanProgressWindow(QWidget):
|
||||
|
||||
# ── 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__()
|
||||
@@ -878,6 +977,9 @@ class MainWindow(QMainWindow):
|
||||
self._scan_progress = ScanProgressWindow()
|
||||
|
||||
self._scan_worker: QtScanController | 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)
|
||||
@@ -999,6 +1101,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Scan
|
||||
self.start_scan_btn.clicked.connect(self._on_start_scan)
|
||||
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)
|
||||
@@ -1172,6 +1275,79 @@ class MainWindow(QMainWindow):
|
||||
self.scan_save_dir_edit.setText(d)
|
||||
self._persist_defaults()
|
||||
|
||||
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.start_scan_btn.setEnabled(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.start_scan_btn.setEnabled(True)
|
||||
self.inspect_angles_btn.setEnabled(True)
|
||||
|
||||
def _on_start_scan(self):
|
||||
try:
|
||||
plan, prefix, save_dir = self._build_scan_plan()
|
||||
|
||||
Reference in New Issue
Block a user