diff --git a/README.md b/README.md index 0d90f95..fef01a6 100755 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ scanengine-3 is a unified platform for scanning acoustic microscopy and precisio - **Laser Systems**: Helios pulsed laser and Genesis CW laser control - **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support - **Scan Planning**: Automated raster scan generation and execution +- **Angle Inspection**: Park the rig at random points across a plan's angles + to check the SAW response on the scope before committing to a long scan - **Real-time Monitoring**: Live status updates and progress tracking ## Hardware Components @@ -55,6 +57,9 @@ scanengine-3/ │ ├── scan_geometry.py # ScanPlan, rotated-bbox planning, limits │ ├── scan_resume.py # Resume planning (frontier rule) │ ├── scope_sras.py # Oscilloscope SCPI policy for SRAS +│ ├── scope_burst.py # Burst-mode FastFrame sizing + row splitting +│ ├── scope_inspect.py # Scope setup for pre-scan angle inspection +│ ├── angle_inspect.py # AngleInspector — park on a point per angle │ ├── rotation.py # GR rotation axis settings + moves │ ├── sras_format.py # v6 .sras writer/reader (memory-mapped) │ ├── sras_analysis.py # Image reducers + SAW matched filter @@ -72,6 +77,7 @@ scanengine-3/ │ ├── gui/ # Shared PyQt6 layer │ ├── scan_bridge.py # QtScanController over core.scan_engine +│ ├── inspect_bridge.py # QtAngleInspector over core.angle_inspect │ ├── qt_t3r.py # Qt adapter over the T3R driver │ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases │ └── widgets.py # ConnectionBar, LogConsole, PortSelector… diff --git a/core/angle_inspect.py b/core/angle_inspect.py new file mode 100644 index 0000000..23d9ae4 --- /dev/null +++ b/core/angle_inspect.py @@ -0,0 +1,251 @@ +"""Pre-scan angle inspection: park the rig on a point and let the operator look. + +A multi-angle scan can take hours, and an angle that responds poorly produces +rows that look fine in the file but carry no usable SAW packet. This drives +the rig through the same angles the scan will use, parking at a random point +inside each angle's own bounding box so the response can be judged on the +oscilloscope before committing to the run. + +Headless and Qt-free, like ScanEngine: gui/inspect_bridge.py wraps it. + +No waveform ever crosses this boundary. The operator reads the scope screen +directly; this module's job is only to put the hardware in the right place and +the scope in a state worth looking at (see core.scope_inspect). +""" +from __future__ import annotations + +import logging +import random +from dataclasses import dataclass +from typing import Callable + +from core import scope_inspect +from core.rotation import RotationAxis +from core.scan_engine import ( + AXIS_X, AXIS_Y, SCAN_ACCEL_MM_S2, SCAN_VELOCITY_MM_S, +) +from core.scan_geometry import DEFAULT_STAGE_LIMITS, ScanPlan, StageLimits + +logger = logging.getLogger(__name__) + +# Positioning moves only — no data is taken while moving, so there is no +# reason to cross the tray at full scan velocity. +INSPECT_VELOCITY_MM_S = SCAN_VELOCITY_MM_S / 2.0 + + +@dataclass(frozen=True) +class InspectionPoint: + """Where the rig is parked, and which angle it is parked for.""" + angle_idx: int + angle_deg: float + x_mm: float + y_mm: float + + def describe(self) -> str: + return (f"Angle {self.angle_idx + 1} ({self.angle_deg:.1f}°) " + f"X={self.x_mm:.3f} mm Y={self.y_mm:.3f} mm") + + +@dataclass +class InspectCallbacks: + """Progress reporting. Defaults are no-ops so the core needs no front end.""" + on_status: Callable[[str], None] = lambda msg: None + on_point: Callable[[InspectionPoint], None] = lambda pt: None + on_busy: Callable[[bool], None] = lambda busy: None + + +@dataclass +class _State: + angle_idx: int = 0 + point: InspectionPoint | None = None + started: bool = False + rotator_ready: bool = False + + +class AngleInspector: + """Drives stage + rotator to inspection points across a plan's angles.""" + + def __init__(self, stage, scope, rotator: RotationAxis | None, + plan: ScanPlan, + callbacks: InspectCallbacks | None = None, + limits: StageLimits = DEFAULT_STAGE_LIMITS, + rng: random.Random | None = None): + self._stage = stage + self._scope = scope + self._rotator = rotator + self._plan = plan + self._cb = callbacks if callbacks is not None else InspectCallbacks() + self._limits = limits + # Injectable so tests can pin the point selection. + self._rng = rng if rng is not None else random.Random() + self._st = _State() + + # ── Introspection ───────────────────────────────────────────────────────── + + @property + def n_angles(self) -> int: + return self._plan.n_angles + + @property + def angle_idx(self) -> int: + return self._st.angle_idx + + @property + def current_point(self) -> InspectionPoint | None: + return self._st.point + + def angle_labels(self) -> list[str]: + return [f"Angle {i + 1}/{self.n_angles} — {pa.angle_deg:.2f}°" + for i, pa in enumerate(self._plan.per_angle)] + + # ── Lifecycle ───────────────────────────────────────────────────────────── + + def start(self) -> InspectionPoint: + """Configure the hardware and park on the first angle.""" + if self._stage is None: + raise RuntimeError("BBD202 not connected") + if self._scope is None: + raise RuntimeError("Oscilloscope not connected") + + self._st.rotator_ready = (self._rotator is not None + and self._rotator.is_available) + if self.n_angles > 1 and not self._st.rotator_ready: + raise RuntimeError( + f"Inspecting {self.n_angles} angles requires the T3R rotation " + "stage (GR-axis), but it is not connected. Connect T3R from " + "the T3R panel, or inspect a single-angle plan." + ) + + self._cb.on_busy(True) + try: + self._cb.on_status("Configuring stage for inspection …") + ctrl = self._stage + for axis in (AXIS_X, AXIS_Y): + ctrl.set_velocity_params(axis, + max_velocity=INSPECT_VELOCITY_MM_S, + acceleration=SCAN_ACCEL_MM_S2) + # Nothing here is gated, and an armed trigger output would keep + # driving the gate line on every positioning move. + ctrl.set_trigger_gate_off(AXIS_X) + + if self._st.rotator_ready: + self._cb.on_status("Configuring GR axis …") + self._rotator.configure() + + self._cb.on_status("Configuring oscilloscope for inspection …") + scope_inspect.configure_inspection(self._scope) + + self._st.started = True + return self._goto(0, new_point=True) + finally: + self._cb.on_busy(False) + + def stop(self) -> None: + """Stop the sweep and send the rotator home. Safe to call twice.""" + if not self._st.started: + return + self._st.started = False + self._cb.on_busy(True) + try: + try: + scope_inspect.stop_inspection(self._scope) + except Exception: + logger.exception("Could not stop the inspection acquisition") + if self._st.rotator_ready and abs(self._rotator.current_deg) > 0.001: + self._cb.on_status("Returning GR to home …") + try: + self._rotator.return_to_zero() + except Exception: + logger.exception("GR return-to-home failed") + self._cb.on_status("Inspection finished.") + finally: + self._cb.on_busy(False) + + # ── Navigation ──────────────────────────────────────────────────────────── + + def goto_angle(self, angle_idx: int) -> InspectionPoint: + """Rotate to `angle_idx` and park on a fresh random point there.""" + self._require_started() + self._cb.on_busy(True) + try: + return self._goto(angle_idx, new_point=True) + finally: + self._cb.on_busy(False) + + def next_angle(self) -> InspectionPoint: + """Advance one angle, wrapping at the end.""" + return self.goto_angle((self._st.angle_idx + 1) % self.n_angles) + + def prev_angle(self) -> InspectionPoint: + return self.goto_angle((self._st.angle_idx - 1) % self.n_angles) + + def new_point(self) -> InspectionPoint: + """Re-roll the point within the current angle, without rotating. + + One point can be unrepresentative — a bad spot on the sample looks the + same as a bad angle. Re-rolling a few times is how you tell them + apart, so this deliberately skips the rotation. + """ + self._require_started() + self._cb.on_busy(True) + try: + return self._goto(self._st.angle_idx, new_point=True, rotate=False) + finally: + self._cb.on_busy(False) + + # ── Internals ───────────────────────────────────────────────────────────── + + def _require_started(self): + if not self._st.started: + raise RuntimeError("Inspection has not been started") + + def _goto(self, angle_idx: int, new_point: bool, + rotate: bool = True) -> InspectionPoint: + if not 0 <= angle_idx < self.n_angles: + raise IndexError( + f"Angle {angle_idx} out of range (plan has {self.n_angles})") + + pa = self._plan.per_angle[angle_idx] + self._st.angle_idx = angle_idx + + if rotate and self._st.rotator_ready: + delta = pa.angle_deg - self._rotator.current_deg + if abs(delta) > 0.001: + self._cb.on_status( + f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …") + self._rotator.rotate_to(pa.angle_deg) + + point = self._pick_point(angle_idx) if new_point else self._st.point + + self._cb.on_status(f"Moving to {point.describe()} …") + # Y first, then X — the same order the scan uses to reach a row. + self._stage.move_axis_absolute(AXIS_Y, point.y_mm, timeout=60.0) + self._stage.move_axis_absolute(AXIS_X, point.x_mm, timeout=60.0) + + self._st.point = point + self._cb.on_point(point) + self._cb.on_status(f"Parked at {point.describe()}") + return point + + def _pick_point(self, angle_idx: int) -> InspectionPoint: + """A random point on this angle's scan grid. + + Y is drawn from the angle's actual row positions and X uniformly from + its data window, so the point is somewhere the scan would really + sample — not merely inside the bounding box. + """ + pa = self._plan.per_angle[angle_idx] + if not pa.y_positions: + raise ValueError(f"Angle {angle_idx + 1} has no rows to inspect") + + y = self._rng.choice(pa.y_positions) + x = self._rng.uniform(pa.x_start, pa.x_start + pa.x_delta) + + lim = self._limits + if not (lim.x_min <= x <= lim.x_max and lim.y_min <= y <= lim.y_max): + raise ValueError( + f"Inspection point X={x:.3f} Y={y:.3f} is outside the stage " + f"travel ({lim.x_min}–{lim.x_max} × {lim.y_min}–{lim.y_max} mm)" + ) + return InspectionPoint(angle_idx=angle_idx, angle_deg=pa.angle_deg, + x_mm=x, y_mm=y) diff --git a/core/scope_inspect.py b/core/scope_inspect.py new file mode 100644 index 0000000..3e305cc --- /dev/null +++ b/core/scope_inspect.py @@ -0,0 +1,104 @@ +"""Oscilloscope configuration for pre-scan angle inspection. + +Inspection is read-on-the-instrument: nothing in this module transfers or +plots waveform data. The app puts the scope into a free-running, edge- +triggered state and drives the stage to the point being inspected; the +operator judges the SAW response and the bias levels on the scope screen. + +That split is deliberate. A scan's acquisition trigger is the logic AND of +the laser pulse and the stage's max-velocity gate, and its transfers are +FastFrame blocks — neither is useful for looking at one point by eye. Here +the trigger is a plain edge on the laser pulse, FastFrame is off, and the +acquisition free-runs, so the display updates continuously while the stage +sits still. + +CH1 keeps the acquisition front-end so what is on screen is what a scan would +record. CH3 and CH4 are rescaled as DC bias monitors (see BIAS_* below). +""" +from __future__ import annotations + +import logging +from dataclasses import replace + +from core.scope_sras import SAMPLE_RATE_HZ, SRAS_CHANNELS, configure_channels + +logger = logging.getLogger(__name__) + +# CH2 carries the laser pulse. The scan triggers it at 0.5 V as one term of a +# logic AND; inspection triggers well above that so a slow edge or a noisy +# baseline cannot free-run the display. +INSPECT_TRIG_LEVEL_V = 2.0 + +# CH3/CH4 are the DC bias monitors during inspection. The signal never goes +# negative and spans roughly 0–700 mV, so both channels get the *same* scale +# and position — the point of inspecting them is comparing the two by eye, and +# that only works if a division means the same thing on each. +# +# Ground sits BIAS_POSITION_DIV divisions below centre, which puts the whole +# 0–700 mV range above the centre line with a little room underneath for +# undershoot. With 100 mV/div and ground 3.5 divisions low, the visible window +# runs from about -50 mV to +750 mV on an 8-division display and wider on a +# 10-division one, so 0–700 mV sits comfortably inside either. +BIAS_CHANNELS = (3, 4) +BIAS_WINDOW_V = 0.700 +BIAS_SCALE_V_DIV = 0.100 +BIAS_POSITION_DIV = -3.5 + +BIAS_LABELS = {3: "Bias - A", 4: "Bias - B"} + + +def inspect_channel_profiles() -> dict: + """Channel front-end config for inspection. + + CH1 and CH2 are the acquisition profiles verbatim. CH3 and CH4 differ + only in label, scale and position — termination, coupling and bandwidth + stay as the scan sets them, so the bias reading is the same measurement + the scan records, just displayed usefully. + """ + profiles = dict(SRAS_CHANNELS) + for ch in BIAS_CHANNELS: + profiles[ch] = replace( + SRAS_CHANNELS[ch], + label=BIAS_LABELS[ch], + scale_v_div=BIAS_SCALE_V_DIV, + position_div=BIAS_POSITION_DIV, + ) + return profiles + + +def configure_inspection(scope) -> None: + """Put the scope into free-running inspection mode. + + Leaves the acquisition running, so the display stays live while the + operator moves between angles and points. + """ + configure_channels(scope, inspect_channel_profiles()) + + # Plain edge trigger on the laser pulse — no logic pattern, so the stage + # gate plays no part and a stationary stage still triggers. + scope.write("TRIGger:A:TYPe EDGE") + scope.set_trigger_source(2) + scope.set_trigger_slope("RISE") + scope.set_trigger_level(2, INSPECT_TRIG_LEVEL_V) + scope.set_trigger_mode("NORMAL") + + # No averaging: a weak or intermittent SAW response is exactly what the + # operator is looking for, and averaging would hide it. + scope.set_acquire_mode("SAMPLE") + scope.set_fastframe_state(False) + + scope.set_sample_rate(SAMPLE_RATE_HZ) + scope.write("HORizontal:POSition 30") + + # Free-run rather than single-sequence, so the trace keeps updating. + scope.write("ACQuire:STOPAfter RUNSTop") + scope.write("ACQuire:STATE RUN") + + +def stop_inspection(scope) -> None: + """Halt the free-running acquisition. + + The next scan reconfigures the scope from scratch, so this only needs to + stop the sweep — it does not try to restore the acquisition profile. + """ + scope.write("ACQuire:STATE STOP") diff --git a/gui/inspect_bridge.py b/gui/inspect_bridge.py new file mode 100644 index 0000000..fed4a4f --- /dev/null +++ b/gui/inspect_bridge.py @@ -0,0 +1,113 @@ +"""Qt bridge over the headless AngleInspector. + +Inspection is command-driven rather than one long run: the operator clicks an +angle, waits for the stage to park, looks at the scope, clicks again. That is +exactly the shape QueueWorker exists for — it blocks on the queue between +commands instead of polling, so an inspection window left open costs nothing. + +Every stage move and rotation blocks for seconds, so all of it runs on this +worker's thread; the window only ever enqueues and reacts to signals. +""" +from __future__ import annotations + +import traceback + +from PyQt6.QtCore import pyqtSignal + +from core.angle_inspect import AngleInspector, InspectCallbacks +from gui.qt_workers import QueueWorker + + +class QtAngleInspector(QueueWorker): + """Runs an AngleInspector on its own QThread and republishes its events.""" + + ready = pyqtSignal(object) # InspectionPoint — start() succeeded + start_failed = pyqtSignal(str) + point_changed = pyqtSignal(object) # InspectionPoint + status_msg = pyqtSignal(str) + busy_changed = pyqtSignal(bool) # True while a move is in flight + stopped = pyqtSignal() + + def __init__(self, stage, scope, rotator, plan, on_inspect_active=None): + super().__init__() + self._on_inspect_active = on_inspect_active + + callbacks = InspectCallbacks( + on_status=self.status_msg.emit, + on_point=self.point_changed.emit, + on_busy=self.busy_changed.emit, + ) + self._inspector = AngleInspector(stage, scope, rotator, plan, + callbacks=callbacks) + self._handlers = { + "start": self._do_start, + "goto": self._do_goto, + "new_point": self._do_new_point, + "stop": self._do_stop, + } + + # ── Introspection (safe from the GUI thread: reads the plan, not the rig) ── + + def angle_labels(self) -> list[str]: + return self._inspector.angle_labels() + + @property + def n_angles(self) -> int: + return self._inspector.n_angles + + # ── Command submission (GUI thread) ─────────────────────────────────────── + + def request_start(self): + self._enqueue("start") + + def request_goto(self, angle_idx: int): + self._enqueue("goto", angle_idx=angle_idx) + + def request_new_point(self): + self._enqueue("new_point") + + def request_stop(self): + self._enqueue("stop") + + # ── Handlers (worker thread) ────────────────────────────────────────────── + + def _do_start(self): + if self._on_inspect_active is not None: + self._on_inspect_active(True) + try: + point = self._inspector.start() + except Exception as exc: + traceback.print_exc() + if self._on_inspect_active is not None: + self._on_inspect_active(False) + self.start_failed.emit(str(exc)) + return + self.ready.emit(point) + + def _do_goto(self, angle_idx: int): + self._inspector.goto_angle(angle_idx) + + def _do_new_point(self): + self._inspector.new_point() + + def _do_stop(self): + try: + self._inspector.stop() + finally: + if self._on_inspect_active is not None: + self._on_inspect_active(False) + self.stopped.emit() + + def _on_stop(self): + """Worker loop exiting — make sure the rig is left in a safe state. + + Covers the case where the window is closed without a clean stop + command reaching the queue. + """ + try: + self._inspector.stop() + except Exception: + traceback.print_exc() + finally: + if self._on_inspect_active is not None: + self._on_inspect_active(False) diff --git a/sc3-aui-main.ui b/sc3-aui-main.ui index b08c1de..91dd41d 100755 --- a/sc3-aui-main.ui +++ b/sc3-aui-main.ui @@ -1017,6 +1017,16 @@ + + + + Rotate through the angles of the scan currently entered, parking at a random point in each so the SAW response can be checked on the oscilloscope before committing to the run. + + + Inspect Angles… + + + diff --git a/sc3_aui_app.py b/sc3_aui_app.py index 58bd640..c5cf7cc 100755 --- a/sc3_aui_app.py +++ b/sc3_aui_app.py @@ -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() diff --git a/tests/test_angle_inspect.py b/tests/test_angle_inspect.py new file mode 100644 index 0000000..7f342d1 --- /dev/null +++ b/tests/test_angle_inspect.py @@ -0,0 +1,292 @@ +"""Pre-scan angle inspection, driven entirely by fake hardware. + +The feature's defining constraint is that it reads nothing back from the +scope — the operator looks at the instrument. These tests pin that, the scope +state the app is responsible for putting the instrument into, and the motion +sequence across angles. +""" +import random + +import pytest + +from core.angle_inspect import AngleInspector, InspectCallbacks +from core.rotation import RotationAxis, RotationSettings +from core.scan_engine import AXIS_X, AXIS_Y +from core.scan_geometry import build_plan +from core.scope_inspect import ( + BIAS_CHANNELS, BIAS_POSITION_DIV, BIAS_SCALE_V_DIV, BIAS_WINDOW_V, + INSPECT_TRIG_LEVEL_V, inspect_channel_profiles, +) +from core.scope_sras import SRAS_CHANNELS +from fakes import FakeScope, FakeStage, FakeT3R, Trace + +SPF = 8 + + +def make_plan(num_angles=3): + return build_plan(40.0, 30.0, 2.0, 1.0, num_angles, 0.25, + laser_freq_hz=20000.0, velocity_mm_s=100.0) + + +def build(num_angles=3, seed=1234, callbacks=None, rotator_open=True): + trace = Trace() + scope = FakeScope(trace, samples_per_frame=SPF) + stage = FakeStage(trace, scope=scope) + t3r = FakeT3R(trace, is_open=rotator_open) + rotator = RotationAxis(t3r, RotationSettings()) + plan = make_plan(num_angles) + insp = AngleInspector(stage, scope, rotator, plan, + callbacks=callbacks or InspectCallbacks(), + rng=random.Random(seed)) + return insp, trace, plan + + +def writes(trace): + return [c[1] for c in trace.of("write")] + + +# ── The defining constraint ────────────────────────────────────────────────── + +def test_inspection_never_reads_a_waveform_back(): + """The operator reads the scope; the app must not pull data off it. + + If this fails, someone has added a transfer path to a feature whose whole + premise is that there isn't one. + """ + insp, trace, plan = build() + insp.start() + for i in range(plan.n_angles): + insp.goto_angle(i) + insp.new_point() + insp.stop() + + forbidden = {"transfer_fastframe", "transfer_fastframe_bulk", + "transfer_curve", "set_data_source", "query_wfmoutpre"} + assert forbidden.isdisjoint(set(trace.names())) + assert "CURVe?" not in writes(trace) + + +# ── Scope configuration ────────────────────────────────────────────────────── + +def test_start_sets_an_edge_trigger_on_ch2_above_the_scan_level(): + insp, trace, _ = build() + insp.start() + + assert "TRIGger:A:TYPe EDGE" in writes(trace) + assert trace.of("set_trigger_source")[-1][1] == 2 + assert trace.of("set_trigger_slope")[-1][1] == "RISE" + ch, level = trace.of("set_trigger_level")[-1][1:3] + assert (ch, level) == (2, INSPECT_TRIG_LEVEL_V) + assert INSPECT_TRIG_LEVEL_V >= 2.0 + + +def test_start_disables_fastframe_averaging_and_the_logic_trigger(): + """Everything the scan needs and inspection must not inherit.""" + insp, trace, _ = build() + insp.start() + + assert trace.of("set_fastframe_state")[-1][1] is False + assert trace.of("set_acquire_mode")[-1][1] == "SAMPLE" + w = writes(trace) + assert not any("LOGIc" in cmd or "LOGICPattern" in cmd for cmd in w) + + +def test_start_leaves_the_acquisition_free_running(): + """The display has to keep updating while the operator looks at it.""" + insp, trace, _ = build() + insp.start() + + w = writes(trace) + assert "ACQuire:STOPAfter RUNSTop" in w + assert w.index("ACQuire:STOPAfter RUNSTop") < w.index("ACQuire:STATE RUN") + assert "ACQuire:STATE STOP" not in w + + +def test_bias_channels_are_directly_comparable(): + """CH3/CH4 must share scale and position or the eye comparison is a lie.""" + profiles = inspect_channel_profiles() + a, b = (profiles[ch] for ch in BIAS_CHANNELS) + assert a.scale_v_div == b.scale_v_div + assert a.position_div == b.position_div + # Same front end as the scan records — only the display changes. + for ch in BIAS_CHANNELS: + assert profiles[ch].termination_ohm == SRAS_CHANNELS[ch].termination_ohm + assert profiles[ch].coupling == SRAS_CHANNELS[ch].coupling + assert profiles[ch].bandwidth_hz == SRAS_CHANNELS[ch].bandwidth_hz + + +@pytest.mark.parametrize("n_divisions", [8, 10]) +def test_bias_window_shows_zero_to_700mv_with_headroom(n_divisions): + """0–700 mV must fit on screen, above ground, on either graticule size. + + Ground sits BIAS_POSITION_DIV divisions below centre, so the visible + window runs from (-N/2 - pos)*scale to (+N/2 - pos)*scale. + """ + half = n_divisions / 2 + bottom = (-half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV + top = (half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV + + assert bottom < 0.0, "no room below ground for undershoot" + assert top > BIAS_WINDOW_V, "700 mV is clipped or sitting on the top edge" + # The point of moving the trace down: most of the screen is above ground. + assert abs(bottom) < top + + +def test_ch1_keeps_the_acquisition_front_end(): + """What you see at a point is what a scan would record there.""" + assert inspect_channel_profiles()[1] == SRAS_CHANNELS[1] + + +# ── Stage and rotation ─────────────────────────────────────────────────────── + +def test_start_parks_on_the_first_angle(): + insp, _, plan = build() + point = insp.start() + + assert point.angle_idx == 0 + assert point.angle_deg == plan.per_angle[0].angle_deg + assert insp.current_point == point + + +def test_the_gate_is_off_for_the_whole_inspection(): + """Nothing here is gated, and an armed output keeps driving the line.""" + insp, trace, _ = build() + insp.start() + insp.goto_angle(2) + insp.new_point() + + assert trace.count("set_trigger_gate_off") >= 1 + assert trace.count("set_trigger_trigout_maxv") == 0 + assert [c[2] for c in trace.of("arm_scan_gate") if c[2]] == [] + + +def test_points_land_on_the_scan_grid(): + """A point the scan would never sample tells you nothing about the scan.""" + insp, _, plan = build() + insp.start() + + for i in range(plan.n_angles): + pa = plan.per_angle[i] + for _ in range(5): + pt = insp.new_point() if insp.angle_idx == i else insp.goto_angle(i) + assert pt.angle_idx == i + assert pt.y_mm in pa.y_positions + assert pa.x_start <= pt.x_mm <= pa.x_start + pa.x_delta + + +def test_goto_angle_rotates_then_moves(): + insp, trace, plan = build() + insp.start() + trace.calls.clear() + + insp.goto_angle(2) + + # t3r_rotate carries the delta, so assert the resulting absolute angle. + assert trace.count("t3r_rotate") == 1, "expected exactly one rotation" + assert insp._rotator.current_deg == pytest.approx(plan.per_angle[2].angle_deg) + moves = trace.of("move_axis_absolute") + assert [m[1] for m in moves] == [AXIS_Y, AXIS_X], "Y then X, as the scan does" + + +def test_new_point_re_rolls_without_rotating(): + """Distinguishing a bad spot from a bad angle depends on not rotating.""" + insp, trace, _ = build() + insp.start() + insp.goto_angle(1) + trace.calls.clear() + + first = insp.current_point + second = insp.new_point() + + assert second.angle_idx == first.angle_idx == 1 + assert (second.x_mm, second.y_mm) != (first.x_mm, first.y_mm) + assert trace.count("t3r_rotate") == 0, "new_point must not rotate" + assert [m[1] for m in trace.of("move_axis_absolute")] == [AXIS_Y, AXIS_X] + + +def test_next_and_prev_wrap_around(): + insp, _, plan = build(num_angles=3) + insp.start() + + assert insp.next_angle().angle_idx == 1 + assert insp.next_angle().angle_idx == 2 + assert insp.next_angle().angle_idx == 0, "should wrap forward" + assert insp.prev_angle().angle_idx == plan.n_angles - 1, "should wrap back" + + +def test_angle_labels_cover_every_angle(): + insp, _, plan = build(num_angles=9) + labels = insp.angle_labels() + assert len(labels) == 9 + assert labels[0].startswith("Angle 1/9") + + +# ── Guards ─────────────────────────────────────────────────────────────────── + +def test_multi_angle_inspection_requires_the_rotator(): + insp, _, _ = build(num_angles=3, rotator_open=False) + with pytest.raises(RuntimeError, match="T3R rotation stage"): + insp.start() + + +def test_single_angle_inspection_works_without_the_rotator(): + insp, _, _ = build(num_angles=1, rotator_open=False) + point = insp.start() + assert point.angle_idx == 0 + + +def test_navigation_before_start_is_rejected(): + insp, _, _ = build() + with pytest.raises(RuntimeError, match="not been started"): + insp.goto_angle(1) + with pytest.raises(RuntimeError, match="not been started"): + insp.new_point() + + +def test_out_of_range_angle_is_rejected(): + insp, _, _ = build(num_angles=3) + insp.start() + with pytest.raises(IndexError): + insp.goto_angle(3) + + +def test_stop_halts_the_sweep_and_sends_the_rotator_home(): + insp, trace, _ = build() + insp.start() + insp.goto_angle(2) + trace.calls.clear() + + insp.stop() + + assert "ACQuire:STATE STOP" in writes(trace) + assert trace.count("t3r_rotate") == 1, "GR not sent home" + assert insp._rotator.current_deg == pytest.approx(0.0) + + +def test_stop_is_idempotent(): + insp, trace, _ = build() + insp.start() + insp.stop() + trace.calls.clear() + + insp.stop() # must not re-issue anything or raise + + assert trace.calls == [] + + +def test_busy_callback_brackets_every_move(): + """The window disables its controls on this, so it has to pair up.""" + events = [] + insp, _, _ = build(callbacks=InspectCallbacks(on_busy=events.append)) + insp.start() + insp.goto_angle(1) + insp.new_point() + insp.stop() + + assert events, "no busy events emitted" + assert events[0] is True and events[-1] is False + depth = 0 + for e in events: + depth += 1 if e else -1 + assert depth in (0, 1), f"unbalanced busy events: {events}" + assert depth == 0