Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23546a03f7 | |||
| 817da0160c | |||
| 52fdcdd9f3 | |||
| ab5f3166f4 |
@@ -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…
|
||||
|
||||
@@ -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)
|
||||
@@ -24,6 +24,7 @@ class ScanDefaults:
|
||||
save_dir: str = str(DEFAULTS_PATH.parent / "scans")
|
||||
helios_port: str = "/dev/ttyUSB2"
|
||||
burst_mode: bool = False
|
||||
strict_rows: bool = False
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path = DEFAULTS_PATH) -> "ScanDefaults":
|
||||
|
||||
+23
-4
@@ -96,7 +96,7 @@ class ScanEngine:
|
||||
plan: ScanPlan, out_path: Path,
|
||||
resume: ResumeState | None = None,
|
||||
callbacks: ScanCallbacks | None = None,
|
||||
burst_mode: bool = False):
|
||||
burst_mode: bool = False, strict_rows: bool = False):
|
||||
self._stage = stage
|
||||
self._scope = scope
|
||||
self._rotator = rotator
|
||||
@@ -107,6 +107,9 @@ class ScanEngine:
|
||||
# Burst mode acquires as many whole rows per FastFrame acquisition as
|
||||
# the scope's frame memory holds, instead of one row per acquisition.
|
||||
self._burst_mode = burst_mode
|
||||
# Strict row packing stops the scan on a frame-count mismatch
|
||||
# instead of squaring the row up (see _check_frame_delta).
|
||||
self._strict_rows = strict_rows
|
||||
self._max_frames = 0
|
||||
self._preflight_done = False
|
||||
|
||||
@@ -434,7 +437,7 @@ class ScanEngine:
|
||||
self._cb.on_status(f"Fetching CH{ch} data …")
|
||||
waveforms = scope_sras.transfer_channel(scope, ch)
|
||||
if ch == SCAN_CHANNELS[0]:
|
||||
self._warn_frame_delta(row_idx, len(waveforms), n_frames)
|
||||
self._check_frame_delta(row_idx, len(waveforms), n_frames)
|
||||
row = scope_burst.normalize_row(
|
||||
b"".join(waveforms), 0, len(waveforms), n_frames, samples_per_frame)
|
||||
if ch == 4:
|
||||
@@ -577,7 +580,7 @@ class ScanEngine:
|
||||
total_frames = sum(counts)
|
||||
|
||||
for r, count in enumerate(counts):
|
||||
self._warn_frame_delta(first_row + r, count, n_frames)
|
||||
self._check_frame_delta(first_row + r, count, n_frames)
|
||||
|
||||
for ch_idx, ch in enumerate(SCAN_CHANNELS):
|
||||
if ch == 3:
|
||||
@@ -608,10 +611,26 @@ class ScanEngine:
|
||||
|
||||
scan_file.seek(burst_start + len(counts) * row_bytes)
|
||||
|
||||
def _warn_frame_delta(self, row_idx: int, count: int, n_frames: int):
|
||||
def _check_frame_delta(self, row_idx: int, count: int, n_frames: int):
|
||||
"""Decide what to do with a row that did not acquire n_frames frames.
|
||||
|
||||
v6 declares n_frames per row in the header and has no per-row length
|
||||
field, so a mismatched row cannot just be written as-is — that would
|
||||
shift every later row in the file. The only two safe options are to
|
||||
square it up or to stop, which is what strict_rows selects between.
|
||||
|
||||
Called before anything for the row is written (CH1 leads
|
||||
SCAN_CHANNELS), so raising here leaves no partial row behind.
|
||||
"""
|
||||
if count == n_frames:
|
||||
return
|
||||
verb = "zero-padded" if count < n_frames else "truncated"
|
||||
if self._strict_rows:
|
||||
raise RuntimeError(
|
||||
f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
|
||||
f"expected. Strict row packing is on, so the scan stops here "
|
||||
f"rather than writing a row that would be {verb}."
|
||||
)
|
||||
msg = (f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
|
||||
f"expected — {verb} to keep the file layout intact.")
|
||||
logger.warning(msg)
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
+4
-2
@@ -32,7 +32,8 @@ class QtScanController(QObject):
|
||||
paused_changed = pyqtSignal(bool) # True while paused at a row boundary
|
||||
|
||||
def __init__(self, stage, scope, rotator, plan, out_path,
|
||||
resume=None, on_scan_active=None, burst_mode=False):
|
||||
resume=None, on_scan_active=None, burst_mode=False,
|
||||
strict_rows=False):
|
||||
super().__init__()
|
||||
self._prompt_event = threading.Event()
|
||||
self._on_scan_active = on_scan_active
|
||||
@@ -48,7 +49,8 @@ class QtScanController(QObject):
|
||||
)
|
||||
self._engine = ScanEngine(stage, scope, rotator, plan, out_path,
|
||||
resume=resume, callbacks=callbacks,
|
||||
burst_mode=burst_mode)
|
||||
burst_mode=burst_mode,
|
||||
strict_rows=strict_rows)
|
||||
|
||||
# ── Engine control (called from the GUI thread) ───────────────────────────
|
||||
|
||||
|
||||
@@ -1007,6 +1007,26 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="strict_rows_check">
|
||||
<property name="toolTip">
|
||||
<string>Stop the scan if a row does not acquire the expected number of frames, instead of zero-padding a short row or truncating a long one. Use for data runs where a silently squared-up row would be worse than a failed scan.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Strict row packing (abort on frame-count mismatch)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="inspect_angles_btn">
|
||||
<property name="toolTip">
|
||||
<string>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.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Inspect Angles…</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="start_scan_btn">
|
||||
<property name="text">
|
||||
|
||||
+180
@@ -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)
|
||||
@@ -918,6 +1020,7 @@ class MainWindow(QMainWindow):
|
||||
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 (
|
||||
@@ -979,6 +1082,7 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
@@ -997,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)
|
||||
@@ -1137,6 +1242,7 @@ class MainWindow(QMainWindow):
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
@@ -1169,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()
|
||||
@@ -1261,6 +1440,7 @@ class MainWindow(QMainWindow):
|
||||
on_scan_active=lambda active: setattr(
|
||||
self._bbd_worker, "scanning_active", active),
|
||||
burst_mode=self.burst_mode_check.isChecked(),
|
||||
strict_rows=self.strict_rows_check.isChecked(),
|
||||
)
|
||||
self._scan_worker.moveToThread(self._scan_thread)
|
||||
self._scan_thread.started.connect(self._scan_worker.run)
|
||||
|
||||
+17
-3
@@ -222,9 +222,23 @@ re-armed for each acquiring pass. Row boundaries inside the burst come from
|
||||
`ACQuire:NUMFRAMESACQuired?` sampled after each pass — the burst itself carries
|
||||
no row markers. See `core/scope_burst.py`.
|
||||
|
||||
Either path squares each row up to the declared `n_frames` (zero-padding a
|
||||
short row, dropping the tail of a long one), because the format has no per-row
|
||||
length field and a mismatch would shift every later row.
|
||||
### Row packing
|
||||
|
||||
The format has no per-row length field, so a row that over- or under-triggers
|
||||
cannot be written as it arrived — that would shift every later row. Two
|
||||
policies are selectable (`ScanEngine(strict_rows=…)`, a checkbox in the app),
|
||||
and the choice is not recorded in the file:
|
||||
|
||||
| | Pad (default) | Strict |
|
||||
|---|---|---|
|
||||
| Short row | zero-padded to `n_frames`, warned | scan stops |
|
||||
| Long row | trailing frames dropped, warned | scan stops |
|
||||
|
||||
Pad keeps a scan running through an occasional mis-trigger, at the cost that
|
||||
the affected row is indistinguishable from a good one afterwards — nothing in
|
||||
the file records that it was padded. Strict is for data runs where that
|
||||
ambiguity is worse than a failed scan: it aborts before writing the row, so
|
||||
the file always ends on a whole-row boundary.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
+80
-13
@@ -27,7 +27,8 @@ def make_plan(num_angles=1, y_delta=0.005):
|
||||
|
||||
|
||||
def build(tmp_path, num_angles=1, callbacks=None, resume=None, plan=None,
|
||||
burst_mode=False, max_frames=4096, out_name="out.sras", **kw):
|
||||
burst_mode=False, max_frames=4096, out_name="out.sras",
|
||||
strict_rows=False, **kw):
|
||||
trace = Trace()
|
||||
scope = FakeScope(trace, samples_per_frame=SPF, max_frames=max_frames)
|
||||
stage = FakeStage(trace, scope=scope)
|
||||
@@ -37,7 +38,7 @@ def build(tmp_path, num_angles=1, callbacks=None, resume=None, plan=None,
|
||||
engine = ScanEngine(stage, scope, rotator, plan, tmp_path / out_name,
|
||||
resume=resume,
|
||||
callbacks=callbacks or ScanCallbacks(),
|
||||
burst_mode=burst_mode)
|
||||
burst_mode=burst_mode, strict_rows=strict_rows)
|
||||
return engine, trace, plan
|
||||
|
||||
|
||||
@@ -356,6 +357,26 @@ def test_burst_preflight_rejects_a_dark_laser(tmp_path):
|
||||
engine.run()
|
||||
|
||||
|
||||
def _clip_one_row(engine, which_pass=2, lost=1):
|
||||
"""Make one acquiring pass come up `lost` frames short.
|
||||
|
||||
`which_pass` counts acquiring passes from 1, so the default clips the
|
||||
second data row (row 2) — far enough in that a mishandled short row shows
|
||||
up as a shift in the rows after it.
|
||||
"""
|
||||
scope = engine._scope
|
||||
real_acquire = scope.acquire_frames
|
||||
passes = {"n": 0}
|
||||
|
||||
def clipped(n):
|
||||
if scope._running:
|
||||
passes["n"] += 1
|
||||
if passes["n"] == which_pass:
|
||||
n -= lost
|
||||
real_acquire(n)
|
||||
scope.acquire_frames = clipped
|
||||
|
||||
|
||||
@pytest.mark.parametrize("burst_mode", [False, True])
|
||||
def test_short_row_is_padded_to_declared_frame_count(tmp_path, burst_mode):
|
||||
"""A clipped row must not shift every later row in the file.
|
||||
@@ -374,17 +395,7 @@ def test_short_row_is_padded_to_declared_frame_count(tmp_path, burst_mode):
|
||||
# acquiring-pass count below identical in both modes.
|
||||
engine._preflight_done = True
|
||||
|
||||
scope = engine._scope
|
||||
real_acquire = scope.acquire_frames
|
||||
passes = {"n": 0}
|
||||
|
||||
def clipped(n):
|
||||
if scope._running:
|
||||
passes["n"] += 1
|
||||
if passes["n"] == 2: # second data row loses one frame
|
||||
n -= 1
|
||||
real_acquire(n)
|
||||
scope.acquire_frames = clipped
|
||||
_clip_one_row(engine)
|
||||
|
||||
result = engine.run()
|
||||
|
||||
@@ -398,6 +409,62 @@ def test_short_row_is_padded_to_declared_frame_count(tmp_path, burst_mode):
|
||||
assert bytes(sras.load_row(0, 2, 0)[0]) != bytes(SPF)
|
||||
|
||||
|
||||
# ── Strict row packing ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("burst_mode", [False, True])
|
||||
def test_strict_row_packing_aborts_on_a_short_row(tmp_path, burst_mode):
|
||||
"""Strict mode fails the scan instead of silently squaring a row up.
|
||||
|
||||
The default padding keeps the file readable but makes a mis-triggered row
|
||||
indistinguishable from a good one after the fact, since v6 records no
|
||||
per-row frame count. Strict mode trades the salvaged rows for knowing.
|
||||
"""
|
||||
plan = make_plan(**BURST_PLAN)
|
||||
engine, _, _ = build(tmp_path, plan=plan, burst_mode=burst_mode,
|
||||
max_frames=BURST_MAX_FRAMES, strict_rows=True)
|
||||
engine._preflight_done = True
|
||||
_clip_one_row(engine)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Row 2: 3 frames acquired, 4 expected"):
|
||||
engine.run()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("burst_mode", [False, True])
|
||||
def test_strict_row_packing_does_not_disturb_a_clean_scan(tmp_path, burst_mode):
|
||||
"""Strict mode is inert when every row acquires what it declared."""
|
||||
plan = make_plan(**BURST_PLAN)
|
||||
engine, _, _ = build(tmp_path, plan=plan, burst_mode=burst_mode,
|
||||
max_frames=BURST_MAX_FRAMES, strict_rows=True)
|
||||
engine._preflight_done = True
|
||||
|
||||
result = engine.run()
|
||||
|
||||
assert result.rows_written == plan.total_rows
|
||||
sras = SrasFile(tmp_path / "out.sras")
|
||||
assert [s.status for s in sras.angle_status()] == ["OK"]
|
||||
|
||||
|
||||
def test_strict_row_packing_writes_nothing_for_the_failed_row(tmp_path):
|
||||
"""The abort must not leave a half-written row behind.
|
||||
|
||||
CH1 leads SCAN_CHANNELS, so the frame count is known before any of the
|
||||
row's channels are written — the file should end on a whole-row boundary.
|
||||
"""
|
||||
plan = make_plan(**BURST_PLAN)
|
||||
engine, _, _ = build(tmp_path, plan=plan, strict_rows=True)
|
||||
engine._preflight_done = True
|
||||
_clip_one_row(engine)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Strict row packing"):
|
||||
engine.run()
|
||||
|
||||
# Row 1 was written in full; row 2 aborted before writing anything, so
|
||||
# the file ends exactly on a row boundary.
|
||||
sras = SrasFile(tmp_path / "out.sras")
|
||||
written = (tmp_path / "out.sras").stat().st_size - sras.data_start_offset
|
||||
assert written == sras.row_bytes(0)
|
||||
|
||||
|
||||
def test_engine_imports_without_qt():
|
||||
"""The engine must be usable from a non-Qt front end."""
|
||||
import subprocess
|
||||
|
||||
Reference in New Issue
Block a user