Merge dev-auto-align: level the sample from the camera window

This commit is contained in:
Thomas Ales
2026-09-04 14:02:04 -05:00
13 changed files with 1868 additions and 19 deletions
+50
View File
@@ -64,3 +64,53 @@ in `configure_scan_trigger` is not enough for FastFrame to re-arm after an
AVERAGE-mode sequence, and the row-packing warning ("N frames acquired, M AVERAGE-mode sequence, and the row-packing warning ("N frames acquired, M
expected") will say so in the log. Raise the settle rather than the ramp expected") will say so in the log. Raise the settle rather than the ramp
buffer — the stage geometry is not what changed. buffer — the stage geometry is not what changed.
## Auto-align: constants that are guesses until the rig confirms them
`core/auto_align.py` closes a loop over hardware whose gain nobody has
measured. Three numbers in `AlignSettings`/`TAxisSettings` are reasoned
defaults, not readings:
- `probe_steps = 200` — the first move of every search, made only to learn how
many millivolts a microstep is worth. Too small and each search wastes
iterations doubling it (the status line says so: "moved N microsteps and the
DC difference did not change"); too large and the first move overshoots by
more than the platform should be asked to travel in one go.
- `max_excursion_steps = 20000` — the per-axis safety limit, measured from
wherever the axis started. It exists to stop a runaway before the actuator
reaches its end stop, so it has to be smaller than the real travel.
- `hold_current_ma = 300` — the run current (600 mA) and microstepping (32)
are specified; the standstill current is half the run current by analogy
with the GR axis, and has not been checked against the platform's weight.
**Bench check:** run one auto-align and read the log. The first search's
iteration count is the probe verdict — 3 or 4 steps means the probe is about
right, and a "did not change by 2 mV" line means it is too small. Convert the
applied corrections into actuator travel and compare against the T-axis
travel to set the excursion limit. Watch whether the platform holds its tilt
between the two phases; if it sags, raise the hold current.
## Auto-align: does the X phase's piston matter, and where is the pivot?
The X phase moves T1 alone, as specified. T1 is the only axis lying along X,
so it does tilt the platform along X — but moving one leg of three also lifts
the platform by a third of the move (`tilt_response(X_TILT)` returns a piston
of 1/3 alongside the 2/3 tilt). The search nulls the split-detector
difference, which a piston should not move, so the assumption is that the
piston is harmless. The piston-free alternative is T1 +1 with T0 and T2 at
−0.5 each.
Separately, the procedure assumes the tilt pivot is under the beam: if it is
not, applying the correction shifts the DC levels at the reference point
itself, and the Y phase then chases levels that no longer describe the rig.
The code reports this rather than compensating for it — `AxisResult`'s
"back at the reference" reading after the X phase is exactly that
measurement.
**Bench check:** during an X search, watch DC1 + DC2 (the sum, not the
difference) on the scope. If the sum moves as T1 moves, the piston is
changing the amount of collected light and `X_TILT` should become the
piston-free triple. Then read the X phase's reference residual out of the
log: more than a few millivolts means the pivot is not under the beam, and
the Y phase's reference should be re-measured after the X correction instead
of reusing the operator's original numbers.
+32 -1
View File
@@ -19,6 +19,9 @@ scanengine-3 is a unified platform for scanning acoustic microscopy and precisio
- **SAW Quality Check**: Acquire one row per angle — the row-wise middle of - **SAW Quality Check**: Acquire one row per angle — the row-wise middle of
the ROI — as a v11 `.sras`, then compare every angle's SAW frequency on one the ROI — as a v11 `.sras`, then compare every angle's SAW frequency on one
graph to judge the alignment before a full run graph to judge the alignment before a full run
- **Auto-Align**: Level the sample from the camera window — step the stage
1.5 mm either side on X and then Y, tilt the T-axes until the DC bias levels
read what they read at the reference point, and leave the correction applied
- **Real-time Monitoring**: Live status updates and progress tracking - **Real-time Monitoring**: Live status updates and progress tracking
## Hardware Components ## Hardware Components
@@ -63,8 +66,9 @@ scanengine-3/
│ ├── scan_resume.py # Resume planning (frontier rule) │ ├── scan_resume.py # Resume planning (frontier rule)
│ ├── scope_sras.py # Oscilloscope SCPI policy for SRAS │ ├── scope_sras.py # Oscilloscope SCPI policy for SRAS
│ ├── scope_burst.py # Burst-mode FastFrame sizing + row splitting │ ├── scope_burst.py # Burst-mode FastFrame sizing + row splitting
│ ├── scope_inspect.py # Scope setup for pre-scan angle inspection │ ├── scope_inspect.py # Scope setup for inspection + bias read-back
│ ├── angle_inspect.py # AngleInspector — park on a point per angle │ ├── angle_inspect.py # AngleInspector — park on a point per angle
│ ├── auto_align.py # AutoAligner — tilt the sample level on the DC levels
│ ├── saw_check.py # Middle-row SAW check: plan + alignment read-out │ ├── saw_check.py # Middle-row SAW check: plan + alignment read-out
│ ├── rotation.py # GR rotation axis settings + moves │ ├── rotation.py # GR rotation axis settings + moves
│ ├── sras_format.py # v7/v11 .sras writer, v6/v10 reader (mmap) │ ├── sras_format.py # v7/v11 .sras writer, v6/v10 reader (mmap)
@@ -84,6 +88,7 @@ scanengine-3/
├── gui/ # Shared PyQt6 layer ├── gui/ # Shared PyQt6 layer
│ ├── scan_bridge.py # QtScanController over core.scan_engine │ ├── scan_bridge.py # QtScanController over core.scan_engine
│ ├── inspect_bridge.py # QtAngleInspector over core.angle_inspect │ ├── inspect_bridge.py # QtAngleInspector over core.angle_inspect
│ ├── align_bridge.py # QtAutoAligner over core.auto_align
│ ├── qt_t3r.py # Qt adapter over the T3R driver │ ├── qt_t3r.py # Qt adapter over the T3R driver
│ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases │ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases
│ ├── jog_panel.py # T3R + BBD202 jog controls (camera window) │ ├── jog_panel.py # T3R + BBD202 jog controls (camera window)
@@ -246,6 +251,32 @@ with SrasFile("/data/SRAS/demo-sawcheck.sras") as sras:
`saw_check_viewer.py` is the same read-out with the curves drawn. `saw_check_viewer.py` is the same read-out with the curves drawn.
### Levelling the sample (auto-align)
Two phases, because the operator sits between them: `prepare()` configures
the rig and reads the DC levels where the stage stands, and `run()` only
starts once those levels have been confirmed as the ones to hold.
```python
from core.auto_align import AlignCallbacks, AutoAligner
aligner = AutoAligner(stage, scope, t3r,
callbacks=AlignCallbacks(on_status=print))
reference = aligner.prepare() # scope + T-axes configured, one reading
print(reference.describe()) # "is the image correct?" happens here
result = aligner.run() # X on T1, then Y on T0/T2
print(result.describe())
aligner.stop() # stage parked; the tilt stays applied
```
The scope has to be cabled CH1 SAW / CH2 trigger / CH3 DC 1 / CH4 DC 2 — the
same channels a scan uses, except that CH3 carries the DC monitor here rather
than the max-velocity gate. Nothing rewires it; the app asks the operator to
confirm the cabling, and refuses to servo on a scope that is not triggering.
In the main app the button is in the camera window, because judging the image
is the first step of the procedure.
### Reading a scan file ### Reading a scan file
`SrasFile` memory-maps the data block, so opening a multi-gigabyte scan `SrasFile` memory-maps the data block, so opening a multi-gigabyte scan
+708
View File
@@ -0,0 +1,708 @@
"""Auto-align: level the sample against the stage's travel plane.
The operator frames a good spot by eye and confirms the DC bias levels the
detector reads there. Those two numbers — DC 1 on CH3, DC 2 on CH4 — are the
definition of "aligned" for this rig, and they are the only thing this module
optimises.
Why moving the stage tells you about tilt: the detection beam is fixed in
space and the XY stage carries the sample under it, so the height of the
surface under the beam is ``h(x) = h0 + theta * x`` when the sample sits at an
angle ``theta`` to the travel plane. Step 1.5 mm along X and the bias levels
move by ``theta * 1.5``; tilt the platform until they read what they read at
the reference point and you have measured ``theta`` directly, because a
platform tilt changes the height under the beam in proportion to x as well.
The correction that fixes the offset point is therefore the same correction
that levels the whole travel — which is why this procedure ends by applying
it and leaving it applied.
Both directions are measured, from the same starting tilt, and the two answers
are averaged. On a flat sample they agree; a disagreement is the read-out
saying the surface is not a plane (or that the platform has backlash), and it
is reported rather than averaged away silently.
The three T-axes form a tip/tilt platform. Their azimuths on the platform
(see T_AXIS_AZIMUTH_DEG) decide which axis corrects which stage direction:
T1 lies along +X, so it alone tilts the platform along X; T0 and T2 sit at
+/-120 degrees from it and have to move as an equal-and-opposite pair to tilt
along Y without also tilting along X. ``tilt_response`` derives that from the
azimuths, so a re-plumbed platform is a one-line change to the azimuth map and
not a re-derivation of the whole procedure.
Qt-free, like ScanEngine and AngleInspector: gui/align_bridge.py wraps it.
"""
from __future__ import annotations
import logging
import math
import time
from dataclasses import dataclass, field
from typing import Callable
from core import scope_inspect
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, StageLimits
logger = logging.getLogger(__name__)
# Where each T-axis sits on the tilt platform, in degrees from the stage's +X
# axis. T1 is co-linear with +X; T0 and T2 are the other two legs of the
# kinematic triangle. This map is the whole geometry — everything else about
# which axis moves when is derived from it.
T_AXIS_AZIMUTH_DEG = {0: 120.0, 1: 0.0, 2: 240.0}
T_AXES = tuple(sorted(T_AXIS_AZIMUTH_DEG))
T_AXIS_LABELS = {ch: f"T{ch}" for ch in T_AXES}
# Positioning moves only, so there is no reason to cross the tray at full scan
# velocity — the same halving the angle inspector uses.
ALIGN_VELOCITY_MM_S = SCAN_VELOCITY_MM_S / 2.0
class AutoAlignError(RuntimeError):
"""The procedure cannot continue: bad rig state, or nothing responding."""
class AutoAlignAborted(RuntimeError):
"""The operator stopped the procedure part-way through."""
# ── Platform geometry ────────────────────────────────────────────────────────
@dataclass(frozen=True)
class TiltGroup:
"""The T-axis move that tilts the platform along one stage axis.
``weights`` maps a T-axis channel to the microsteps it contributes per
unit of correction, so a correction of ``c`` moves channel ``ch`` by
``c * weights[ch]``.
"""
stage_axis: int # APT axis address of the stage axis this corrects
label: str # "X" or "Y", for the operator
weights: dict[int, float]
def describe(self) -> str:
if len(self.weights) == 1:
return T_AXIS_LABELS[next(iter(self.weights))]
return " / ".join(f"{T_AXIS_LABELS[ch]} {w:+.0f}"
for ch, w in sorted(self.weights.items()))
# X is corrected by the one axis that lies along it, and Y by the other two
# driven equal and opposite — that pairing is what makes the Y move a pure
# tilt along Y (tilt_response(Y_TILT) has no X term), so the two phases of the
# procedure do not fight each other. Moving T1 alone does raise the platform
# as well as tilt it, which the search absorbs: it nulls a measured level, not
# a model of the platform.
X_TILT = TiltGroup(AXIS_X, "X", {1: +1.0})
Y_TILT = TiltGroup(AXIS_Y, "Y", {0: +1.0, 2: -1.0})
TILT_GROUPS = (X_TILT, Y_TILT)
def tilt_response(group: TiltGroup) -> tuple[float, float, float]:
"""What one unit of ``group`` does to the platform: (piston, x_tilt, y_tilt).
The three actuators define a plane, so their heights fix it exactly:
fitting ``z = piston + x_tilt * x + y_tilt * y`` through the three
(azimuth, weight) points is a closed-form solution on a symmetric triangle
— the mean is the piston and the projections onto x and y are the tilts,
scaled by 2/3 because each actuator sits one unit radius out.
Used to check the groups above are the moves they claim to be, and to say
in one place what "moving T0 and T2 as a pair" actually produces.
"""
heights = {ch: group.weights.get(ch, 0.0) for ch in T_AXES}
piston = sum(heights.values()) / len(T_AXES)
x_tilt = y_tilt = 0.0
for ch, h in heights.items():
theta = math.radians(T_AXIS_AZIMUTH_DEG[ch])
x_tilt += h * math.cos(theta)
y_tilt += h * math.sin(theta)
scale = 2.0 / len(T_AXES)
return piston, x_tilt * scale, y_tilt * scale
# ── Settings ─────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class TAxisSettings:
"""Drive settings for the three T-axes during the procedure.
32 microsteps and 600 mA are the operating point this procedure is
specified at; they are applied to all three axes at the start rather than
trusted from whatever the T3R panel last left behind, because the search
reports its corrections in microsteps and a different microstep setting
would silently change what a step means.
"""
microsteps: int = 32
run_current_ma: int = 600
hold_current_ma: int = 300 # half of run: holds the platform, runs cool
ihold_delay: int = 6
velocity: int = 4000 # steps/s — small moves, so ramps dominate
accel: int = 2000 # steps/s^2
@dataclass(frozen=True)
class AlignSettings:
"""How far to step, how close to get, and how hard to try."""
offset_mm: float = 1.5 # stage step either side of the reference
tolerance_mv: float = 5.0 # "same DC values" means within this
probe_steps: int = 200 # first move of a search: gain is unknown
max_step_steps: int = 2000 # per-iteration clamp on a correction
max_excursion_steps: int = 20000 # per-axis limit from the starting tilt
max_iterations: int = 25
max_probe_doublings: int = 4 # escalation when a probe reads as no response
min_response_mv: float = 2.0 # below this a probe has told us nothing
settle_s: float = 0.3 # after a move, before believing a reading
acquisition_retries: int = 2 # re-reads before calling the scope stalled
reads_per_measurement: int = scope_inspect.BIAS_READS
move_timeout_margin_s: float = 5.0
stage_timeout_s: float = 60.0
DEFAULT_T_AXIS = TAxisSettings()
DEFAULT_ALIGN = AlignSettings()
# ── Read-out ─────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class Reading:
"""One measurement of the two DC bias levels, in millivolts."""
dc1_mv: float
dc2_mv: float
@property
def difference_mv(self) -> float:
"""DC 1 - DC 2.
The split-detector difference is what a tilt actually steers, so it is
the signal the search drives to zero; the sum is set by the laser and
the surface reflectivity, which no amount of tilting will change.
"""
return self.dc1_mv - self.dc2_mv
def error_vs(self, ref: "Reading") -> tuple[float, float]:
return self.dc1_mv - ref.dc1_mv, self.dc2_mv - ref.dc2_mv
def difference_error_vs(self, ref: "Reading") -> float:
return self.difference_mv - ref.difference_mv
def matches(self, ref: "Reading", tolerance_mv: float) -> bool:
return all(abs(e) <= tolerance_mv for e in self.error_vs(ref))
def describe(self) -> str:
return f"DC1 {self.dc1_mv:+.1f} mV, DC2 {self.dc2_mv:+.1f} mV"
@dataclass
class OffsetResult:
"""What one search — one stage offset, one tilt group — ended up doing."""
axis_label: str
offset_mm: float
correction_steps: float
iterations: int
nulled: bool # difference back within tolerance: the tilt loop worked
converged: bool # both levels back within tolerance: the operator's test
reason: str
final: Reading
def describe(self) -> str:
return (f"{self.axis_label}{self.offset_mm:+.2f} mm: "
f"{self.correction_steps:+.0f} usteps in {self.iterations} steps "
f"→ {self.final.describe()} ({self.reason})")
@dataclass
class AxisResult:
"""Both offsets for one stage axis, and the tilt they agreed on."""
axis_label: str
group: TiltGroup
offsets: list[OffsetResult]
applied_steps: float
disagreement_steps: float
applied: bool
reference_residual: Reading | None # measured back at the reference point
@property
def ok(self) -> bool:
return self.applied and all(o.nulled for o in self.offsets)
def describe(self) -> str:
if not self.applied:
return (f"{self.axis_label}: no correction applied — "
+ "; ".join(o.reason for o in self.offsets))
residual = (f", back at the reference {self.reference_residual.describe()}"
if self.reference_residual else "")
return (f"{self.axis_label}: applied {self.applied_steps:+.0f} usteps on "
f"{self.group.describe()} (the two directions disagreed by "
f"{self.disagreement_steps:.0f} usteps){residual}")
@dataclass
class AlignResult:
"""The whole procedure, as the summary the operator is shown."""
reference: Reading
axes: list[AxisResult] = field(default_factory=list)
final: Reading | None = None
tolerance_mv: float = DEFAULT_ALIGN.tolerance_mv
@property
def ok(self) -> bool:
return bool(self.axes) and all(a.ok for a in self.axes) and (
self.final is not None
and self.final.matches(self.reference, self.tolerance_mv))
def verdict(self) -> str:
"""The closing lines: where it ended up, and whether that is aligned."""
lines = []
if self.final is not None:
d1, d2 = self.final.error_vs(self.reference)
lines.append(f"Final at the reference point: {self.final.describe()} "
f"({d1:+.1f} / {d2:+.1f} mV from the good values)")
lines.append("Aligned." if self.ok else
"Finished without meeting the tolerance — see the log.")
return "\n".join(lines)
def describe(self) -> str:
lines = [f"Reference: {self.reference.describe()}"]
for axis in self.axes:
lines.append(axis.describe())
lines.extend(f" {o.describe()}" for o in axis.offsets)
lines.append(self.verdict())
return "\n".join(lines)
@dataclass
class AlignCallbacks:
"""Progress reporting. Defaults are no-ops so the core needs no front end."""
on_status: Callable[[str], None] = lambda msg: None
on_reading: Callable[[Reading], None] = lambda r: None
on_busy: Callable[[bool], None] = lambda busy: None
on_offset_done: Callable[[OffsetResult], None] = lambda r: None
on_axis_done: Callable[[AxisResult], None] = lambda r: None
# ── The tilt platform ────────────────────────────────────────────────────────
class _TiltPlatform:
"""The three T-axes, driven in microsteps relative to where they started.
Positions are tracked as floats and commanded as integers, with the
rounding residue carried forward, so a long run of fractional corrections
cannot drift the platform away from what the procedure thinks it applied.
"""
def __init__(self, driver, settings: TAxisSettings, max_excursion_steps: int,
timeout_margin_s: float = 5.0):
self._driver = driver
self._s = settings
self._max = max_excursion_steps
self._timeout_margin_s = timeout_margin_s
self._target = {ch: 0.0 for ch in T_AXES} # wanted, fractional
self._actual = {ch: 0 for ch in T_AXES} # commanded, integral
@property
def positions(self) -> dict[int, int]:
return dict(self._actual)
def configure(self) -> None:
s = self._s
for ch in T_AXES:
self._driver.set_microstep(ch, s.microsteps)
self._driver.set_current(ch, s.run_current_ma, s.hold_current_ma,
s.ihold_delay)
self._driver.enable(ch)
def snapshot(self) -> dict[int, float]:
return dict(self._target)
def apply(self, group: TiltGroup, amount: float) -> None:
"""Move the group by ``amount`` units of its weights."""
self._goto({ch: self._target[ch] + amount * w
for ch, w in group.weights.items()})
def restore(self, snapshot: dict[int, float]) -> None:
self._goto(snapshot)
def _goto(self, targets: dict[int, float]) -> None:
for ch, target in targets.items():
if abs(target) > self._max:
raise AutoAlignError(
f"{T_AXIS_LABELS[ch]} would travel {target:+.0f} microsteps "
f"from where it started, past the {self._max} microstep "
f"safety limit. Stopping before the actuator runs out of "
f"travel — align the rig by hand and start again.")
self._target[ch] = target
delta = round(target) - self._actual[ch]
if delta:
self._move(ch, delta)
self._actual[ch] += delta
def _move(self, ch: int, steps: int) -> None:
s = self._s
self._driver.move(ch, steps, s.velocity, s.accel)
timeout = (abs(steps) / s.velocity + s.velocity / s.accel
+ self._timeout_margin_s)
if not self._driver.wait_motion_done(ch, timeout):
logger.warning("%s did not report MOTION_DONE within %.1f s for a "
"%+d microstep move; continuing",
T_AXIS_LABELS[ch], timeout, steps)
# ── The procedure ────────────────────────────────────────────────────────────
class AutoAligner:
"""Drives the stage and the tilt platform to level the sample.
Usage is two-phase because the operator sits in the middle of it:
``prepare()`` puts the rig in a known state and reads the bias levels at
the reference point, the operator confirms the image and those levels are
the ones to hold, then ``run()`` measures and applies the tilt.
"""
def __init__(self, stage, scope, t3r,
settings: AlignSettings = DEFAULT_ALIGN,
t_axis: TAxisSettings = DEFAULT_T_AXIS,
limits: StageLimits = DEFAULT_STAGE_LIMITS,
callbacks: AlignCallbacks | None = None,
should_abort: Callable[[], bool] = lambda: False):
self._stage = stage
self._scope = scope
self._t3r = t3r
self._s = settings
self._cb = callbacks if callbacks is not None else AlignCallbacks()
self._limits = limits
self._should_abort = should_abort
self._platform = _TiltPlatform(t3r, t_axis, settings.max_excursion_steps,
settings.move_timeout_margin_s)
self._reference: Reading | None = None
self._ref_mm: tuple[float, float] | None = None
self._last_acq: int | None = None
self._started = False
# ── Lifecycle ─────────────────────────────────────────────────────────────
@property
def reference(self) -> Reading | None:
return self._reference
@property
def reference_position_mm(self) -> tuple[float, float] | None:
return self._ref_mm
def prepare(self) -> Reading:
"""Configure the rig and read the bias levels where it stands.
The returned reading is a candidate, not yet the reference: the
operator has to confirm the camera image is the one to align on before
anything moves.
"""
self._require_hardware()
self._cb.on_busy(True)
try:
x_mm, y_mm = self._stage_position()
self._check_travel(x_mm, y_mm)
self._ref_mm = (x_mm, y_mm)
self._cb.on_status("Configuring stage …")
for axis in (AXIS_X, AXIS_Y):
self._stage.set_velocity_params(axis,
max_velocity=ALIGN_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.
self._stage.set_trigger_gate_off(AXIS_X)
self._cb.on_status("Configuring oscilloscope …")
scope_inspect.configure_inspection(self._scope)
self._cb.on_status(
f"Configuring T-axes ({DEFAULT_T_AXIS.microsteps} usteps, "
f"{DEFAULT_T_AXIS.run_current_ma} mA) …")
self._platform.configure()
self._started = True
self._cb.on_status("Reading the DC levels at the reference point …")
reading = self._measure()
self._reference = reading
return reading
finally:
self._cb.on_busy(False)
def run(self) -> AlignResult:
"""Measure and apply the tilt, X first and then Y.
Y follows X because the two corrections are independent moves (see
``tilt_response``) but not independent measurements: the X phase is
the one that can reveal a platform whose pivot is not under the beam,
and its reference residual is reported before Y adds to it.
"""
if not self._started or self._reference is None:
raise AutoAlignError("prepare() must run, and the operator must "
"confirm the image, before run()")
result = AlignResult(reference=self._reference,
tolerance_mv=self._s.tolerance_mv)
self._cb.on_busy(True)
try:
for group in TILT_GROUPS:
axis_result = self._align_axis(group)
result.axes.append(axis_result)
self._cb.on_axis_done(axis_result)
if not axis_result.applied:
break
result.final = self._measure()
return result
finally:
self._cb.on_busy(False)
def stop(self) -> None:
"""Park the rig: stage back at the reference point, scope idle.
The tilt correction stays applied — it is the result. Safe to call
twice, and safe to call after a failure part-way through.
"""
if not self._started:
return
self._started = False
self._cb.on_busy(True)
try:
if self._ref_mm is not None:
try:
self._cb.on_status("Returning to the reference point …")
# Deliberately not abort-checked: this *is* the response to
# an abort, and a stop that left the stage 1.5 mm off the
# operator's point would be worse than no stop at all.
self._goto_reference(check_abort=False)
except Exception:
logger.exception("Could not return the stage to the "
"reference point")
try:
scope_inspect.stop_inspection(self._scope)
except Exception:
logger.exception("Could not stop the inspection acquisition")
self._cb.on_status("Auto-align finished.")
finally:
self._cb.on_busy(False)
# ── One stage axis ────────────────────────────────────────────────────────
def _align_axis(self, group: TiltGroup) -> AxisResult:
name = group.label
start_tilt = self._platform.snapshot()
offsets: list[OffsetResult] = []
for sign in (+1.0, -1.0):
offset_mm = sign * self._s.offset_mm
self._cb.on_status(
f"{name}: stepping {offset_mm:+.2f} mm and re-tilting on "
f"{group.describe()} …")
self._goto_offset(group, offset_mm)
offsets.append(self._null(group, name, offset_mm))
self._cb.on_offset_done(offsets[-1])
# Both directions are measured from the same tilt, so they are two
# independent estimates of the same angle rather than one estimate
# and one correction to it.
self._platform.restore(start_tilt)
applied = 0.5 * sum(o.correction_steps for o in offsets)
disagreement = abs(offsets[0].correction_steps - offsets[1].correction_steps)
can_apply = all(o.nulled for o in offsets)
if can_apply:
self._cb.on_status(f"{name}: applying {applied:+.0f} microsteps …")
self._platform.apply(group, applied)
else:
self._cb.on_status(
f"{name}: no correction applied — a search did not null the "
f"DC difference, so the tilt it found means nothing.")
residual = None
self._cb.on_status(f"{name}: back to the reference point …")
self._goto_reference()
if can_apply:
residual = self._measure()
return AxisResult(axis_label=name, group=group, offsets=offsets,
applied_steps=applied if can_apply else 0.0,
disagreement_steps=disagreement,
applied=can_apply, reference_residual=residual)
# ── One search ────────────────────────────────────────────────────────────
def _null(self, group: TiltGroup, name: str, offset_mm: float) -> OffsetResult:
"""Tilt until the bias levels read what they read at the reference.
A secant search on the split-detector difference: probe once to learn
how many millivolts a microstep is worth (the sign included — which
way is "up" is a wiring question this refuses to assume), then step
straight at the null and re-estimate the slope from each pair of
readings.
Ends on one of three outcomes, which mean different things:
*converged* — both levels back inside the tolerance, the result the
operator asked for; *difference nulled* — the beam is back on the
centre of the detector but both levels sit at the wrong height, which
no tilt can fix and which therefore still leaves a usable tilt answer;
anything else means the search failed and its answer must not be used.
"""
s = self._s
reading = self._measure()
applied = 0.0
gain: float | None = None # mV of difference error per microstep
probe = float(s.probe_steps)
doublings = 0
for iteration in range(1, s.max_iterations + 1):
err = reading.difference_error_vs(self._reference)
d1, d2 = reading.error_vs(self._reference)
self._cb.on_status(
f"{name}{offset_mm:+.2f} mm, step {iteration}: "
f"DC1 {d1:+.1f} / DC2 {d2:+.1f} mV from the good values")
if reading.matches(self._reference, s.tolerance_mv):
return self._offset_result(name, offset_mm, applied, iteration,
True, True, "within tolerance", reading)
if abs(err) <= s.tolerance_mv:
return self._offset_result(
name, offset_mm, applied, iteration, True, False,
f"difference nulled, but both levels are off by "
f"{0.5 * (d1 + d2):+.1f} mV — not something tilt can fix",
reading)
if gain is None:
step = probe
else:
step = max(-s.max_step_steps, min(s.max_step_steps, -err / gain))
if abs(step) < 1.0:
step = math.copysign(1.0, step)
self._platform.apply(group, step)
applied += step
previous, reading = reading, self._measure()
response = reading.difference_error_vs(self._reference) - err
if abs(response) >= s.min_response_mv:
gain = response / step
elif gain is None:
# The probe moved nothing measurable. Usually the probe is
# simply too small for this actuator's pitch, so escalate
# before concluding the axis is dead.
self._platform.apply(group, -step)
applied -= step
reading = previous
doublings += 1
if doublings > s.max_probe_doublings:
raise AutoAlignError(
f"{group.describe()} moved {probe:.0f} microsteps and "
f"the DC difference did not change by "
f"{s.min_response_mv:.0f} mV. Check that the laser is "
f"pulsing, that CH3/CH4 are the DC monitors, and that "
f"the T-axes are energised.")
probe *= 2.0
return self._offset_result(
name, offset_mm, applied, s.max_iterations, False, False,
f"gave up after {s.max_iterations} steps", reading)
def _offset_result(self, name, offset_mm, applied, iterations, nulled,
converged, reason, reading) -> OffsetResult:
return OffsetResult(axis_label=name, offset_mm=offset_mm,
correction_steps=applied, iterations=iterations,
nulled=nulled, converged=converged, reason=reason,
final=reading)
# ── Hardware ──────────────────────────────────────────────────────────────
def _require_hardware(self):
if self._stage is None:
raise AutoAlignError("BBD202 not connected")
if self._scope is None:
raise AutoAlignError("Oscilloscope not connected")
if self._t3r is None or not self._t3r.is_open:
raise AutoAlignError(
"The T3R is not connected, so the T-axes cannot be moved. "
"Connect it from the T3R panel and try again.")
def _stage_position(self) -> tuple[float, float]:
try:
return float(self._stage.positions[0]), float(self._stage.positions[1])
except Exception as exc:
raise AutoAlignError(f"Cannot read the stage position: {exc}") from exc
def _check_travel(self, x_mm: float, y_mm: float) -> None:
"""Both offsets on both axes have to be reachable before anything moves."""
off = self._s.offset_mm
lim = self._limits
for name, value, lo, hi in (("X", x_mm, lim.x_min, lim.x_max),
("Y", y_mm, lim.y_min, lim.y_max)):
if value - off < lo or value + off > hi:
raise AutoAlignError(
f"{name} is at {value:.3f} mm, and this procedure needs "
f"{off:.2f} mm either side of it — that leaves the "
f"{lo:g}–{hi:g} mm travel. Move to a point further from "
f"the end of travel and try again.")
def _goto_offset(self, group: TiltGroup, offset_mm: float) -> None:
x_mm, y_mm = self._ref_mm
target = (x_mm if group.stage_axis == AXIS_X else y_mm) + offset_mm
self._move_stage(group.stage_axis, target)
def _goto_reference(self, check_abort: bool = True) -> None:
x_mm, y_mm = self._ref_mm
self._move_stage(AXIS_X, x_mm, check_abort=check_abort)
self._move_stage(AXIS_Y, y_mm, check_abort=check_abort)
def _move_stage(self, stage_axis: int, position_mm: float,
check_abort: bool = True) -> None:
if check_abort:
self._check_abort()
self._stage.move_axis_absolute(stage_axis, position_mm,
timeout=self._s.stage_timeout_s)
def _measure(self) -> Reading:
"""Settle, check the scope is still acquiring, then read both levels."""
self._check_abort()
time.sleep(self._s.settle_s)
self._require_fresh_acquisition()
dc1, dc2 = scope_inspect.read_bias_mv(self._scope,
self._s.reads_per_measurement)
reading = Reading(dc1_mv=dc1, dc2_mv=dc2)
self._cb.on_reading(reading)
return reading
def _require_fresh_acquisition(self) -> None:
"""Refuse to servo on a record the scope has not re-taken.
A stale record reads as a perfectly stable measurement, which is the
one failure this loop cannot see for itself: it would keep stepping
the actuators against a number that never moves.
The very first reading has no previous count to compare against, so it
makes one — waiting for the count to move rather than assuming it
does. That reading becomes the reference the operator confirms and
the whole procedure then chases, which makes it the worst one to take
off a scope that is not triggering.
"""
baseline = self._last_acq
count = self._scope.get_acquisition_count()
if baseline is None:
baseline = count
for _ in range(self._s.acquisition_retries):
if count != baseline:
self._last_acq = count
return
time.sleep(self._s.settle_s)
count = self._scope.get_acquisition_count()
if count == baseline:
raise AutoAlignError(
"The oscilloscope has not triggered since the last reading, "
"so its DC levels are stale. Check that the laser is pulsing "
"and that CH2 carries the trigger.")
self._last_acq = count
def _check_abort(self) -> None:
if self._should_abort():
raise AutoAlignAborted("Auto-align stopped by the operator")
+32
View File
@@ -14,6 +14,11 @@ sits still.
CH1 keeps the acquisition front-end so what is on screen is what a scan would 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). record. CH3 and CH4 are rescaled as DC bias monitors (see BIAS_* below).
``read_bias_mv`` is the one exception to "nothing is transferred": it reads
the two bias levels back as scalars, not waveforms, because the auto-align
procedure (core.auto_align) has to close a loop on them. The operator still
watches the same screen this configures.
""" """
from __future__ import annotations from __future__ import annotations
@@ -46,6 +51,12 @@ BIAS_POSITION_DIV = -3.5
BIAS_LABELS = {3: "Bias - A", 4: "Bias - B"} BIAS_LABELS = {3: "Bias - A", 4: "Bias - B"}
# One MEAN measurement carries the shot-to-shot noise of a single record, and
# the alignment loop has to resolve 5 mV. The median of a handful of reads
# rejects the odd outlier without the averaging acquisition mode, which would
# hide exactly the intermittent response the operator is watching CH1 for.
BIAS_READS = 5
def inspect_channel_profiles() -> dict: def inspect_channel_profiles() -> dict:
"""Channel front-end config for inspection. """Channel front-end config for inspection.
@@ -102,3 +113,24 @@ def stop_inspection(scope) -> None:
stop the sweep — it does not try to restore the acquisition profile. stop the sweep — it does not try to restore the acquisition profile.
""" """
scope.write("ACQuire:STATE STOP") scope.write("ACQuire:STATE STOP")
def read_bias_mv(scope, reads: int = BIAS_READS) -> tuple[float, float]:
"""Read the two DC bias levels in millivolts.
Returns ``(ch3_mv, ch4_mv)`` — DC 1 and DC 2 in the auto-align channel
map. Each channel is read ``reads`` times and reduced by the median.
The two channels are read in separate batches rather than interleaved:
switching the immediate-measurement source costs a round trip, and these
are DC levels, so the few milliseconds between the batches are not a
source of error the way they would be for a transient.
"""
if reads < 1:
raise ValueError("read_bias_mv needs at least one read per channel")
levels = []
for ch in BIAS_CHANNELS:
samples = sorted(scope.measure_immediate(ch, "MEAN") for _ in range(reads))
levels.append(samples[len(samples) // 2] * 1000.0)
return levels[0], levels[1]
+124
View File
@@ -0,0 +1,124 @@
"""Qt bridge over the headless AutoAligner.
The procedure is two long blocking runs with an operator decision between
them — ``prepare()`` puts the rig in a known state and reads the reference
levels, the operator confirms the camera image, then ``run()`` spends a minute
or two moving the stage and the tilt platform. Both belong on a worker
thread; the window only enqueues and reacts to signals.
Stopping cannot go through the command queue: while ``run()`` is executing,
the worker is inside a handler and will not look at the queue until it
returns. The stop request is therefore a threading.Event the core polls
between moves (``should_abort``), and the queued "stop" command only handles
the tidy-up afterwards.
"""
from __future__ import annotations
import threading
import traceback
from PyQt6.QtCore import pyqtSignal
from core.auto_align import AlignCallbacks, AutoAligner, AutoAlignAborted
from gui.qt_workers import QueueWorker
class QtAutoAligner(QueueWorker):
"""Runs an AutoAligner on its own QThread and republishes its events."""
prepared = pyqtSignal(object) # Reading — the candidate reference
prepare_failed = pyqtSignal(str)
status_msg = pyqtSignal(str)
reading_taken = pyqtSignal(object) # Reading
busy_changed = pyqtSignal(bool)
offset_done = pyqtSignal(object) # OffsetResult
axis_done = pyqtSignal(object) # AxisResult
finished = pyqtSignal(object) # AlignResult
failed = pyqtSignal(str)
aborted = pyqtSignal()
stopped = pyqtSignal()
def __init__(self, stage, scope, t3r, settings=None, on_align_active=None):
super().__init__()
self._on_align_active = on_align_active
self._abort = threading.Event()
callbacks = AlignCallbacks(
on_status=self.status_msg.emit,
on_reading=self.reading_taken.emit,
on_busy=self.busy_changed.emit,
on_offset_done=self.offset_done.emit,
on_axis_done=self.axis_done.emit,
)
kwargs = {"settings": settings} if settings is not None else {}
self._aligner = AutoAligner(stage, scope, t3r, callbacks=callbacks,
should_abort=self._abort.is_set, **kwargs)
self._handlers = {
"prepare": self._do_prepare,
"run": self._do_run,
"stop": self._do_stop,
}
# ── Command submission (GUI thread) ───────────────────────────────────────
def request_prepare(self):
self._abort.clear()
self._enqueue("prepare")
def request_run(self):
self._enqueue("run")
def request_abort(self):
"""Stop the procedure at the next move, wherever it has got to."""
self._abort.set()
def request_stop(self):
self._abort.set()
self._enqueue("stop")
# ── Handlers (worker thread) ──────────────────────────────────────────────
def _do_prepare(self):
if self._on_align_active is not None:
self._on_align_active(True)
try:
reading = self._aligner.prepare()
except Exception as exc:
traceback.print_exc()
if self._on_align_active is not None:
self._on_align_active(False)
self.prepare_failed.emit(str(exc))
return
self.prepared.emit(reading)
def _do_run(self):
try:
result = self._aligner.run()
except AutoAlignAborted:
self.status_msg.emit("Auto-align stopped.")
self.aborted.emit()
return
except Exception as exc:
traceback.print_exc()
self.failed.emit(str(exc))
return
self.finished.emit(result)
def _do_stop(self):
try:
self._aligner.stop()
finally:
if self._on_align_active is not None:
self._on_align_active(False)
self.stopped.emit()
def _on_stop(self):
"""Worker loop exiting — leave the rig parked even if the window went
away without a clean stop command reaching the queue."""
try:
self._aligner.stop()
except Exception:
traceback.print_exc()
finally:
if self._on_align_active is not None:
self._on_align_active(False)
+4 -11
View File
@@ -15,13 +15,13 @@ already does it.
from __future__ import annotations from __future__ import annotations
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDoubleSpinBox, QFrame, QGridLayout, QGroupBox, QCheckBox, QComboBox, QDoubleSpinBox, QFrame, QGridLayout, QGroupBox,
QLabel, QPushButton, QSpinBox, QLabel, QPushButton, QSpinBox,
) )
import hardware.t3r_protocol as proto import hardware.t3r_protocol as proto
from gui.widgets import mono_font
from hardware.t3r_driver import T3RDriver from hardware.t3r_driver import T3RDriver
# BBD202 jog defaults, shared with the main window's worker. # BBD202 jog defaults, shared with the main window's worker.
@@ -44,13 +44,6 @@ BBD_JOG_REPEAT_MS = 200
BBD_VELOCITY_DEBOUNCE_MS = 300 BBD_VELOCITY_DEBOUNCE_MS = 300
def _mono(size: int = 11) -> QFont:
f = QFont("Menlo")
f.setStyleHint(QFont.StyleHint.Monospace)
f.setPointSize(size)
return f
def _hline() -> QFrame: def _hline() -> QFrame:
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.Shape.HLine) line.setFrameShape(QFrame.Shape.HLine)
@@ -157,7 +150,7 @@ class T3RJogPanel(QGroupBox):
self._micro_combos[ch] = combo self._micro_combos[ch] = combo
pos_lbl = QLabel("—") pos_lbl = QLabel("—")
pos_lbl.setFont(_mono(11)) pos_lbl.setFont(mono_font(11))
pos_lbl.setMinimumWidth(76) pos_lbl.setMinimumWidth(76)
pos_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) pos_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
grid.addWidget(pos_lbl, row, 4) grid.addWidget(pos_lbl, row, 4)
@@ -280,11 +273,11 @@ class BBDJogPanel(QGroupBox):
grid.addWidget(QLabel("X"), row, 0) grid.addWidget(QLabel("X"), row, 0)
self.x_pos_lbl = QLabel("---.---") self.x_pos_lbl = QLabel("---.---")
self.x_pos_lbl.setFont(_mono(11)) self.x_pos_lbl.setFont(mono_font(11))
grid.addWidget(self.x_pos_lbl, row, 1) grid.addWidget(self.x_pos_lbl, row, 1)
grid.addWidget(QLabel("Y"), row, 2) grid.addWidget(QLabel("Y"), row, 2)
self.y_pos_lbl = QLabel("---.---") self.y_pos_lbl = QLabel("---.---")
self.y_pos_lbl.setFont(_mono(11)) self.y_pos_lbl.setFont(mono_font(11))
grid.addWidget(self.y_pos_lbl, row, 3) grid.addWidget(self.y_pos_lbl, row, 3)
row += 1 row += 1
+9 -4
View File
@@ -17,6 +17,14 @@ from PyQt6.QtWidgets import (
from hardware.serial_util import scored_ports from hardware.serial_util import scored_ports
def mono_font(size: int = 11) -> QFont:
"""Monospace font for numeric read-outs, so columns of digits line up."""
font = QFont("Menlo")
font.setStyleHint(QFont.StyleHint.Monospace)
font.setPointSize(size)
return font
def set_toggle(btn, checked: bool, text: str, enabled: bool = True): def set_toggle(btn, checked: bool, text: str, enabled: bool = True):
"""Update a checkable button without re-triggering its toggled signal.""" """Update a checkable button without re-triggering its toggled signal."""
btn.blockSignals(True) btn.blockSignals(True)
@@ -138,10 +146,7 @@ class LogConsole(QWidget):
self.view = QPlainTextEdit() self.view = QPlainTextEdit()
self.view.setReadOnly(True) self.view.setReadOnly(True)
self.view.setMaximumBlockCount(max_blocks) self.view.setMaximumBlockCount(max_blocks)
font = QFont("Menlo") self.view.setFont(mono_font())
font.setStyleHint(QFont.StyleHint.Monospace)
font.setPointSize(11)
self.view.setFont(font)
layout.addWidget(self.view) layout.addWidget(self.view)
row = QHBoxLayout() row = QHBoxLayout()
+66
View File
@@ -344,6 +344,72 @@ class TektronixOscilloscopeBase:
self.write(f"CH{channel}:TERmination {termination}") self.write(f"CH{channel}:TERmination {termination}")
# ========== Measurement Methods ==========
# Immediate measurements the alignment/inspection code asks for. The
# instrument accepts many more; this list is what has been exercised here,
# and an unlisted type is far more likely to be a typo than a deliberate
# choice.
MEASUREMENT_TYPES = {
'MEAN': ['MEAN'],
'AMPLITUDE': ['AMPlitude', 'AMPLITUDE'],
'MAXIMUM': ['MAXimum', 'MAXIMUM'],
'MINIMUM': ['MINImum', 'MINIMUM'],
'PK2PK': ['PK2pk', 'PK2PK'],
'RMS': ['RMS'],
}
# Tektronix returns this sentinel when a measurement cannot be made (no
# acquisition yet, source off, signal outside the graticule). It is a
# valid float, so it has to be caught explicitly or it reads as a
# 1e38 V measurement.
MEASUREMENT_INVALID = 9.9e37
def measure_immediate(self, channel, measurement_type='MEAN'):
"""Take an immediate measurement on one channel and return it in volts.
"Immediate" measurements are computed on demand and are not added to
the scope's on-screen measurement badges, so this leaves whatever the
operator has set up on the front panel untouched.
Raises ValueError if the instrument reports the measurement as
unavailable, which on a triggered-acquisition scope usually means it
has not acquired anything yet.
"""
channel = self._normalize_channel(channel)
if measurement_type.upper() not in self.MEASUREMENT_TYPES:
raise ValueError(
f"Invalid measurement type: {measurement_type}. "
f"Valid options: {', '.join(self.MEASUREMENT_TYPES)}")
self.write(f"MEASUrement:IMMed:SOUrce1 CH{channel}")
self.write(f"MEASUrement:IMMed:TYPe {measurement_type}")
response = self.query("MEASUrement:IMMed:VALue?")
try:
value = float(response)
except ValueError as exc:
raise ValueError(
f"Unparseable {measurement_type} measurement on CH{channel}: "
f"{response!r}") from exc
if abs(value) >= self.MEASUREMENT_INVALID:
raise ValueError(
f"CH{channel} {measurement_type} is unavailable (the scope "
f"returned its no-measurement sentinel). Check that the "
f"channel is on and that the acquisition is triggering.")
return value
def get_acquisition_count(self):
"""Number of acquisitions since the acquisition was last started.
A caller polling a free-running scope uses this to tell a fresh
reading from a stale one: if the count has not moved, the record has
not changed and every measurement taken off it is the previous
answer.
"""
return int(float(self.query("ACQuire:NUMACq?")))
# ========== Waveform Transfer Methods ========== # ========== Waveform Transfer Methods ==========
def set_data_source(self, source): def set_data_source(self, source):
+10
View File
@@ -78,6 +78,16 @@
</item> </item>
</layout> </layout>
</item> </item>
<item>
<widget class="QPushButton" name="uc480_auto_align_btn">
<property name="text">
<string>Auto-Align…</string>
</property>
<property name="toolTip">
<string>Level the sample: step the stage 1.5 mm each way and re-tilt the T-axes until the DC bias levels read what they read here.</string>
</property>
</widget>
</item>
<item> <item>
<widget class="QPushButton" name="uc480_close_window_btn"> <widget class="QPushButton" name="uc480_close_window_btn">
<property name="text"> <property name="text">
+369 -3
View File
@@ -18,8 +18,8 @@ from PyQt6.QtCore import QThread, QTimer, Qt, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel, QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit,
QSizePolicy, QVBoxLayout, QWidget, QPushButton, QSizePolicy, QVBoxLayout, QWidget,
) )
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure from matplotlib.figure import Figure
@@ -27,6 +27,7 @@ from matplotlib.figure import Figure
ROOT = Path(__file__).parent ROOT = Path(__file__).parent
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from core.auto_align import DEFAULT_ALIGN, DEFAULT_T_AXIS
from core.config import ScanDefaults from core.config import ScanDefaults
from core.rotation import DEFAULT_ROTATION, RotationAxis from core.rotation import DEFAULT_ROTATION, RotationAxis
from core.scan_engine import ( from core.scan_engine import (
@@ -36,11 +37,13 @@ from core.scan_engine import (
from core.saw_check import middle_row_plan from core.saw_check import middle_row_plan
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta
from core.scan_resume import is_compatible, plan_resume from core.scan_resume import is_compatible, plan_resume
from core.scope_inspect import BIAS_LABELS
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import ( from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, WRITABLE_VERSIONS, SrasFile, SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, WRITABLE_VERSIONS, SrasFile,
plan_from_header, plan_from_header,
) )
from gui.align_bridge import QtAutoAligner
from gui.jog_panel import ( from gui.jog_panel import (
BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM, BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM,
BBDJogPanel, T3RJogPanel, BBDJogPanel, T3RJogPanel,
@@ -49,6 +52,7 @@ from gui.qt_t3r import QtT3RAdapter
from gui.qt_workers import PollingQueueWorker, QueueWorker from gui.qt_workers import PollingQueueWorker, QueueWorker
from gui.inspect_bridge import QtAngleInspector from gui.inspect_bridge import QtAngleInspector
from gui.scan_bridge import QtScanController from gui.scan_bridge import QtScanController
from gui.widgets import mono_font
from hardware.helios_laser import HeliosLaser from hardware.helios_laser import HeliosLaser
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
from hardware.tektronix_base import TektronixOscilloscopeBase from hardware.tektronix_base import TektronixOscilloscopeBase
@@ -439,18 +443,62 @@ class HeliosWorker(PollingQueueWorker):
# ── Camera window popup ─────────────────────────────────────────────────────── # ── Camera window popup ───────────────────────────────────────────────────────
# The channel map auto-align assumes. Nothing reconfigures the scope inputs —
# CH3 is the max-velocity gate during a scan and the DC 1 monitor here — so the
# operator is asked to confirm the cabling instead of being trusted to
# remember it.
ALIGN_SCOPE_CHANNELS = ((1, "SAW"), (2, "Trigger"), (3, "DC 1"), (4, "DC 2"))
def _align_scope_prompt() -> str:
channels = "\n".join(f" CH{ch} — {role}" for ch, role in ALIGN_SCOPE_CHANNELS)
# The channels arrive on screen carrying the inspection labels, which are
# the names for the same two monitors — say so, or the operator is left
# comparing "DC 1" here against "Bias - A" on the instrument.
labels = " and ".join(BIAS_LABELS[ch] for ch in sorted(BIAS_LABELS))
return (
"Auto-align reads the DC levels off CH3 and CH4. Check the scope is "
"cabled this way before starting:\n\n"
f"{channels}\n\n"
f"The scope will label the last two {labels}.\n\n"
"The laser must be pulsing and the detection beam on the sample — the "
"procedure stops rather than servo on a scope that is not triggering.\n\n"
"Start auto-align?"
)
def _align_reference_prompt(reading) -> str:
return (
"The detector reads, where the stage is standing:\n\n"
f" DC 1 {reading.dc1_mv:8.1f} mV\n"
f" DC 2 {reading.dc2_mv:8.1f} mV\n\n"
"Is the image correct?\n\n"
f"Yes takes these as the good values and holds them to within "
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV at "
f"{DEFAULT_ALIGN.offset_mm:.2f} mm either side of here, first on X "
f"(T1) and then on Y (T0/T2 as a pair)."
)
class CameraWindow(QWidget): class CameraWindow(QWidget):
"""Camera display popup — auto-connects on show, auto-disconnects on close. """Camera display popup — auto-connects on show, auto-disconnects on close.
Focusing and framing are done by eye, so the T3R and BBD202 jog controls Focusing and framing are done by eye, so the T3R and BBD202 jog controls
live beside the image. Both take the objects the main window already live beside the image. Both take the objects the main window already
owns; passing neither leaves the window as a plain viewer. owns; passing neither leaves the window as a plain viewer.
Auto-align lives here for the same reason: the operator judges the image
to decide the rig is on a good spot, and that judgement is the first step
of the procedure. It needs the oscilloscope as well, so the button is
only offered when all three are on hand.
""" """
closed = pyqtSignal() closed = pyqtSignal()
align_active = pyqtSignal(bool) # lock the scan panel while aligning
def __init__(self, t3r_driver: QtT3RAdapter | None = None, def __init__(self, t3r_driver: QtT3RAdapter | None = None,
bbd_worker: BBD202Worker | None = None, bbd_worker: BBD202Worker | None = None,
oscope_worker: "OscopeWorker | None" = None,
parent: QWidget | None = None): parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Window) super().__init__(parent, Qt.WindowType.Window)
uic.loadUi(ROOT / "sc3-aui-camera.ui", self) uic.loadUi(ROOT / "sc3-aui-camera.ui", self)
@@ -459,6 +507,14 @@ class CameraWindow(QWidget):
self._camera: UC480Camera | None = None self._camera: UC480Camera | None = None
self._stream: CameraStreamThread | None = None self._stream: CameraStreamThread | None = None
self._t3r_driver = t3r_driver
self._bbd_worker = bbd_worker
self._oscope_worker = oscope_worker
self._align_thread: QThread | None = None
self._align_worker: QtAutoAligner | None = None
self._align_window: "AutoAlignWindow | None" = None
self._align_available = True
# Banner warning when the camera shares a USB controller with serial # Banner warning when the camera shares a USB controller with serial
# adapters — opening any of those ports (T3R, BBD202, …) collapses # adapters — opening any of those ports (T3R, BBD202, …) collapses
# the delivered frame rate to <2 fps (USB split-transaction # the delivered frame rate to <2 fps (USB split-transaction
@@ -489,6 +545,13 @@ class CameraWindow(QWidget):
self.uc480_stop_btn.clicked.connect(self._stop_stream) self.uc480_stop_btn.clicked.connect(self._stop_stream)
self.uc480_close_window_btn.clicked.connect(self.close) self.uc480_close_window_btn.clicked.connect(self.close)
# Every one of the three is load-bearing: the T3R moves the tilt
# platform, the BBD202 makes the 1.5 mm steps, and the scope is the
# only thing that can say whether either helped.
self.uc480_auto_align_btn.clicked.connect(self._on_auto_align)
self.uc480_auto_align_btn.setVisible(
None not in (t3r_driver, bbd_worker, oscope_worker))
self.t3r_jog_panel = self.bbd_jog_panel = None self.t3r_jog_panel = self.bbd_jog_panel = None
self._build_jog_column(t3r_driver, bbd_worker) self._build_jog_column(t3r_driver, bbd_worker)
@@ -515,6 +578,8 @@ class CameraWindow(QWidget):
self._start_stream() self._start_stream()
def closeEvent(self, event): def closeEvent(self, event):
# An alignment outlives this window otherwise, and it owns the stage.
self._teardown_align()
# A jog whose button-release lands after the window is gone would # A jog whose button-release lands after the window is gone would
# otherwise leave an axis running. # otherwise leave an axis running.
for panel in (self.t3r_jog_panel, self.bbd_jog_panel): for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
@@ -609,6 +674,298 @@ class CameraWindow(QWidget):
self.uc480_exposure_slider.setEnabled(camera_ok) self.uc480_exposure_slider.setEnabled(camera_ok)
self.uc480_gain_slider.setEnabled(camera_ok) self.uc480_gain_slider.setEnabled(camera_ok)
# ── Auto-align ────────────────────────────────────────────────────────────
def _on_auto_align(self):
if self._align_thread is not None and self._align_thread.isRunning():
if self._align_window is not None:
self._align_window.raise_()
self._align_window.activateWindow()
return
problem = self._align_prerequisite_problem()
if problem:
QMessageBox.warning(self, "Cannot Auto-Align", problem)
return
if QMessageBox.question(self, "Check the Oscilloscope",
_align_scope_prompt()) \
!= QMessageBox.StandardButton.Yes:
return
self._align_thread = QThread(self)
self._align_worker = QtAutoAligner(
stage=self._bbd_worker.controller,
scope=self._oscope_worker.scope,
t3r=self._t3r_driver.driver,
# Same reason the scan and the inspector do it: the procedure
# drives the stage from its own thread, and the position poll
# shares the BBD TX queue.
on_align_active=lambda active: setattr(
self._bbd_worker, "scanning_active", active),
)
self._align_worker.moveToThread(self._align_thread)
window = AutoAlignWindow(self)
self._align_window = window
worker = self._align_worker
worker.status_msg.connect(window.on_status)
worker.reading_taken.connect(window.on_reading)
worker.offset_done.connect(window.on_offset_done)
worker.axis_done.connect(window.on_axis_done)
worker.prepared.connect(self._on_align_prepared)
worker.prepare_failed.connect(self._on_align_prepare_failed)
worker.finished.connect(self._on_align_finished)
worker.failed.connect(self._on_align_failed)
worker.aborted.connect(self._on_align_aborted)
worker.stopped.connect(self._release_align_thread)
worker.error_occurred.connect(window.on_failed)
window.abort_requested.connect(worker.request_abort)
window.close_requested.connect(self._teardown_align)
self._align_thread.started.connect(worker.run)
self._set_align_ui_active(True)
window.show()
self._align_thread.start()
worker.request_prepare()
def _align_prerequisite_problem(self) -> str:
"""Why auto-align cannot start, or "" if it can."""
if self._oscope_worker is None or not self._oscope_worker.is_connected:
return ("The oscilloscope is not connected, and it is the only "
"thing that can read the DC levels. Connect it from the "
"main window and try again.")
if self._bbd_worker is None or not self._bbd_worker.is_connected:
return ("The BBD202 stage is not connected, so the 1.5 mm steps "
"cannot be made. Connect it from the main window and try "
"again.")
if self._t3r_driver is None or not self._t3r_driver.is_open:
return ("The T3R is not connected, so the T-axes cannot be moved. "
"Connect it from the T3R panel and try again.")
return ""
def _on_align_prepared(self, reading):
"""The rig is configured and standing on the point — ask the operator."""
if self._align_window is None:
return
confirmed = QMessageBox.question(
self, "Is the Image Correct?", _align_reference_prompt(reading)
) == QMessageBox.StandardButton.Yes
if not confirmed:
self._align_window.on_status(
"Cancelled: the image was not confirmed. Nothing has moved — "
"re-align by hand and start again.")
self._finish_align()
return
self._align_window.set_reference(reading)
self._align_worker.request_run()
def _on_align_prepare_failed(self, message: str):
if self._align_window is not None:
self._align_window.on_failed(message)
QMessageBox.warning(self, "Cannot Auto-Align", message)
self._finish_align()
def _on_align_finished(self, result):
if self._align_window is not None:
self._align_window.on_finished(result)
self._finish_align()
def _on_align_failed(self, message: str):
if self._align_window is not None:
self._align_window.on_failed(message)
QMessageBox.warning(self, "Auto-Align Failed", message)
self._finish_align()
def _on_align_aborted(self):
if self._align_window is not None:
self._align_window.on_aborted()
self._finish_align()
def _finish_align(self):
"""Park the rig and release the thread, leaving the summary on screen."""
if self._align_worker is not None:
self._align_worker.request_stop()
def _release_align_thread(self):
"""Worker idle — take its thread down. Safe to call more than once."""
if self._align_worker is not None:
self._align_worker.stop_worker()
if self._align_thread is not None:
self._align_thread.quit()
self._align_thread.wait(30000)
self._align_thread = None
self._align_worker = None
self._set_align_ui_active(False)
def _teardown_align(self):
"""Stop an alignment and close its window — the window or camera is
going away, so the procedure cannot be left running."""
window, self._align_window = self._align_window, None
if self._align_worker is not None:
self._align_worker.request_stop()
self._release_align_thread()
if window is not None:
window.close()
def set_align_available(self, available: bool):
"""The main window handing the rig over, or taking it back.
A scan or an angle inspection owns the same stage, so auto-align has
to be off the table while either runs.
"""
self._align_available = available
self._refresh_align_button()
def _refresh_align_button(self):
self.uc480_auto_align_btn.setEnabled(
self._align_available and self._align_thread is None)
def _set_align_ui_active(self, active: bool):
"""A running alignment owns the stage and the T-axes; nothing else
may drive them, here or in the main window."""
self._refresh_align_button()
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
if panel is not None:
panel.stop_jogs()
panel.setEnabled(not active)
self.align_active.emit(active)
class AutoAlignWindow(QWidget):
"""Live progress for one auto-align run, and the summary it ends with.
Shows the deviation from the good values rather than the raw levels: the
procedure is a null search, so how far off it is says more than what it
reads, and 5 mV out of ~400 mV does not show up in the raw number.
"""
abort_requested = pyqtSignal()
close_requested = pyqtSignal()
def __init__(self, parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("Auto-Align")
self.resize(560, 480)
self._reference = None
self._done = False
layout = QVBoxLayout(self)
self.header_label = QLabel(
f"Stepping {DEFAULT_ALIGN.offset_mm:.2f} mm either side of this "
f"point on X (T1), then on Y (T0/T2 as a pair), re-tilting until "
f"the DC levels come back to within "
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV.\n"
f"T-axes: {DEFAULT_T_AXIS.microsteps} µsteps, "
f"{DEFAULT_T_AXIS.run_current_ma} mA.")
self.header_label.setWordWrap(True)
layout.addWidget(self.header_label)
self.reading_label = QLabel("—")
self.reading_label.setFont(mono_font(13))
self.reading_label.setStyleSheet("font-weight: bold;")
layout.addWidget(self.reading_label)
self.status_label = QLabel("Configuring the rig …")
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
# The verdict outlives the status line, which keeps reporting the
# parking moves after the answer is known.
self.verdict_label = QLabel()
self.verdict_label.setWordWrap(True)
self.verdict_label.setVisible(False)
layout.addWidget(self.verdict_label)
self.log = QPlainTextEdit(self)
self.log.setReadOnly(True)
self.log.setFont(mono_font(11))
layout.addWidget(self.log, 1)
buttons = QHBoxLayout()
self.stop_btn = QPushButton("Stop")
self.stop_btn.setToolTip(
"Stop at the next move and put the stage back on the reference "
"point. Any tilt already applied stays applied.")
self.stop_btn.clicked.connect(self._on_stop_clicked)
self.close_btn = QPushButton("Close")
self.close_btn.clicked.connect(self.close)
self.close_btn.setEnabled(False)
buttons.addWidget(self.stop_btn)
buttons.addWidget(self.close_btn)
layout.addLayout(buttons)
# ── Worker → window ───────────────────────────────────────────────────────
def set_reference(self, reading):
self._reference = reading
self._append(f"Reference: {reading.describe()}")
def on_status(self, msg: str):
self.status_label.setText(msg)
def on_reading(self, reading):
if self._reference is None:
self.reading_label.setText(reading.describe())
return
d1, d2 = reading.error_vs(self._reference)
self.reading_label.setText(
f"DC1 {reading.dc1_mv:8.1f} mV ({d1:+6.1f}) "
f"DC2 {reading.dc2_mv:8.1f} mV ({d2:+6.1f})")
def on_offset_done(self, result):
self._append(f" {result.describe()}")
def on_axis_done(self, result):
self._append(result.describe())
def on_finished(self, result):
# The per-axis lines are already in the log, put there as they
# happened; only the closing verdict is new.
self._append("")
self._append(result.verdict())
self._set_verdict(result.verdict(), ok=result.ok)
def on_failed(self, message: str):
self._append(f"FAILED: {message}")
self._set_verdict(message, ok=False)
def on_aborted(self):
message = ("Stopped by the operator. Any tilt already applied stays "
"applied.")
self._append(message)
self._set_verdict(message, ok=False)
# ── Window → worker ───────────────────────────────────────────────────────
def _on_stop_clicked(self):
self.stop_btn.setEnabled(False)
self.on_status("Stopping at the next move …")
self.abort_requested.emit()
def _set_verdict(self, text: str, ok: bool):
self.verdict_label.setText(text)
self.verdict_label.setStyleSheet(
f"font-weight: bold; color: {'green' if ok else '#b8860b'};")
self.verdict_label.setVisible(True)
self._done = True
self.stop_btn.setEnabled(False)
self.close_btn.setEnabled(True)
def _append(self, line: str):
self.log.appendPlainText(line)
def closeEvent(self, event):
# Closing part-way through is a stop: the procedure owns the stage,
# and nothing else can call it off once this window is gone.
if not self._done:
self.abort_requested.emit()
self.close_requested.emit()
super().closeEvent(event)
# ── Helios laser panel ──────────────────────────────────────────────────────── # ── Helios laser panel ────────────────────────────────────────────────────────
@@ -1024,7 +1381,8 @@ class MainWindow(QMainWindow):
self._helios_thread.started.connect(self._helios_worker.run) self._helios_thread.started.connect(self._helios_worker.run)
# ── Popup windows ───────────────────────────────────────────────────── # ── Popup windows ─────────────────────────────────────────────────────
self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker) self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker,
self._oscope_worker)
self._helios_win = HeliosWindow(self._helios_worker) self._helios_win = HeliosWindow(self._helios_worker)
self._scan_progress = ScanProgressWindow() self._scan_progress = ScanProgressWindow()
@@ -1146,6 +1504,7 @@ class MainWindow(QMainWindow):
self._camera_win.closed.connect( self._camera_win.closed.connect(
lambda: self._set_toggle(self.show_camera_toggle, False, "Show Camera Window") lambda: self._set_toggle(self.show_camera_toggle, False, "Show Camera Window")
) )
self._camera_win.align_active.connect(self._on_camera_align_active)
# Helios # Helios
self.show_helios_toggle.toggled.connect(self._on_helios_toggle) self.show_helios_toggle.toggled.connect(self._on_helios_toggle)
@@ -1331,6 +1690,13 @@ class MainWindow(QMainWindow):
"""Both entry points drive the same rig, so they lock and unlock together.""" """Both entry points drive the same rig, so they lock and unlock together."""
self.start_scan_btn.setEnabled(enabled) self.start_scan_btn.setEnabled(enabled)
self.saw_check_btn.setEnabled(enabled) self.saw_check_btn.setEnabled(enabled)
# Auto-align lives in the camera window but drives this same stage.
self._camera_win.set_align_available(enabled)
def _on_camera_align_active(self, active: bool):
"""An alignment started or finished in the camera window."""
self._set_scan_buttons_enabled(not active)
self.inspect_angles_btn.setEnabled(not active)
def _on_browse_save_dir(self): def _on_browse_save_dir(self):
d = QFileDialog.getExistingDirectory( d = QFileDialog.getExistingDirectory(
+107
View File
@@ -258,6 +258,9 @@ class FakeT3R:
self._t = trace self._t = trace
self.is_open = is_open self.is_open = is_open
self._motion_completes = motion_completes self._motion_completes = motion_completes
# Microsteps commanded per channel, so a test can read the tilt the
# platform ended up at rather than replaying the move trace.
self.positions = {ch: 0 for ch in range(4)}
def set_microstep(self, ch, micro): def set_microstep(self, ch, micro):
self._t.record("t3r_set_microstep", ch, micro) self._t.record("t3r_set_microstep", ch, micro)
@@ -273,9 +276,113 @@ class FakeT3R:
return round(self.MOTOR_FULL_STEPS_PER_REV * microsteps * ratio return round(self.MOTOR_FULL_STEPS_PER_REV * microsteps * ratio
* angle_deg / 360.0) * angle_deg / 360.0)
def move(self, ch, steps, velocity, accel):
self._t.record("t3r_move", ch, steps)
self.positions[ch] += steps
def rotate_stage(self, angle_deg, microsteps, velocity, accel): def rotate_stage(self, angle_deg, microsteps, velocity, accel):
self._t.record("t3r_rotate", round(angle_deg, 6)) self._t.record("t3r_rotate", round(angle_deg, 6))
def wait_motion_done(self, ch, timeout): def wait_motion_done(self, ch, timeout):
self._t.record("t3r_wait_motion_done", ch) self._t.record("t3r_wait_motion_done", ch)
return self._motion_completes return self._motion_completes
class FakeAlignRig:
"""A tilted sample on the tilt platform, as the DC levels would read it.
The detection beam is fixed and the stage carries the sample under it, so
the height error under the beam is the sample's slope times how far the
stage has moved off the reference point. The T-axes tilt the sample the
other way: their three heights define a plane, and its slope adds to the
sample's. Nulling the split-detector difference therefore means cancelling
the sample slope — which is exactly what an aligner has to work out.
The plane is fitted by least squares here, rather than reusing
core.auto_align's closed form, so the two are independent statements of
the same geometry.
``curvature_mv_per_mm2`` bends the surface: a curved sample needs opposite
corrections at +1.5 mm and -1.5 mm, which is the disagreement the
procedure is supposed to report instead of averaging away.
"""
# Actuator azimuths on the platform, in degrees from stage +X.
AZIMUTH_DEG = {0: 120.0, 1: 0.0, 2: 240.0}
def __init__(self, stage, t3r, ref_mm=(50.0, 40.0),
x_slope_mv_per_mm=40.0, y_slope_mv_per_mm=-25.0,
tilt_gain_mv_per_mm=0.2, base_mv=400.0,
curvature_mv_per_mm2=0.0, jitter_mv=0.0):
self._stage = stage
self._t3r = t3r
self.ref_mm = ref_mm
self.x_slope_mv_per_mm = x_slope_mv_per_mm
self.y_slope_mv_per_mm = y_slope_mv_per_mm
self.tilt_gain_mv_per_mm = tilt_gain_mv_per_mm
self.base_mv = base_mv
self.curvature_mv_per_mm2 = curvature_mv_per_mm2
self.jitter_mv = jitter_mv
self._reads = 0
# -- geometry -----------------------------------------------------------
def platform_tilt(self):
"""(x_tilt, y_tilt) of the plane through the three actuator heights."""
import numpy as np
rows, heights = [], []
for ch, azimuth in self.AZIMUTH_DEG.items():
theta = np.radians(azimuth)
rows.append([1.0, np.cos(theta), np.sin(theta)])
heights.append(float(self._t3r.positions[ch]))
_, x_tilt, y_tilt = np.linalg.lstsq(np.array(rows), np.array(heights),
rcond=None)[0]
return float(x_tilt), float(y_tilt)
def slopes_mv_per_mm(self):
"""The residual sample slope the beam sees, after the platform tilt."""
x_tilt, y_tilt = self.platform_tilt()
return (self.x_slope_mv_per_mm + self.tilt_gain_mv_per_mm * x_tilt,
self.y_slope_mv_per_mm + self.tilt_gain_mv_per_mm * y_tilt)
def difference_mv(self):
x_off = self._stage.positions[0] - self.ref_mm[0]
y_off = self._stage.positions[1] - self.ref_mm[1]
slope_x, slope_y = self.slopes_mv_per_mm()
return (slope_x * x_off + slope_y * y_off
+ self.curvature_mv_per_mm2 * (x_off ** 2 + y_off ** 2))
# -- what the scope reports ---------------------------------------------
def level_v(self, channel):
"""CH3 and CH4 as volts: the difference straddling a constant sum.
The sum is fixed because tilt steers the beam across the detector
rather than changing how much light comes back — so a nulled
difference does put both levels back where they were.
"""
self._reads += 1
# A deterministic alternating wobble, so a test can check the median
# of several reads is what keeps the loop stable.
jitter = self.jitter_mv * (1 if self._reads % 2 else -1)
half = 0.5 * self.difference_mv()
mv = self.base_mv + (half if channel == 3 else -half) + jitter
return mv / 1000.0
class FakeAlignScope(FakeScope):
"""FakeScope that also answers the DC measurements auto-align reads."""
def __init__(self, trace: Trace, rig: FakeAlignRig, samples_per_frame=8,
acquisitions_advance=True):
super().__init__(trace, samples_per_frame=samples_per_frame)
self._rig = rig
self._acq = 0
self._advance = acquisitions_advance
def measure_immediate(self, channel, measurement_type="MEAN"):
self._t.record("measure_immediate", channel, measurement_type)
return self._rig.level_v(channel)
def get_acquisition_count(self):
if self._advance:
self._acq += 1
return self._acq
+312
View File
@@ -0,0 +1,312 @@
"""Auto-align, driven entirely by fake hardware.
The feature is a closed loop over hardware, so the tests are built round a
model of the rig (fakes.FakeAlignRig): a sample at a known tilt, a platform
whose three actuators tilt it, and DC levels that follow from both. A test
therefore asks the question the operator does — is the sample level now? —
rather than replaying a command sequence.
The other half is geometry. Which axis moves for which stage direction is
the one thing here that cannot be discovered at run time, and getting it
wrong would still converge (on the wrong axis, at the wrong point), so it is
pinned separately and explicitly.
"""
from dataclasses import replace
import pytest
from core.auto_align import (
AutoAligner, AutoAlignAborted, AutoAlignError, DEFAULT_ALIGN,
T_AXIS_AZIMUTH_DEG, X_TILT, Y_TILT, tilt_response,
)
from core.scan_engine import AXIS_X, AXIS_Y
from core.scope_inspect import BIAS_CHANNELS, BIAS_SCALE_V_DIV, INSPECT_TRIG_LEVEL_V
from fakes import FakeAlignRig, FakeAlignScope, FakeStage, FakeT3R, Trace
REF_MM = (50.0, 40.0)
# No settle: the sleeps are there for the instrument, and every test here
# takes a few dozen readings.
FAST = replace(DEFAULT_ALIGN, settle_s=0.0)
def build(*, settings=FAST, acquisitions_advance=True, ref_mm=REF_MM,
should_abort=lambda: False, **rig_kwargs):
trace = Trace()
stage = FakeStage(trace)
stage.positions = list(ref_mm)
t3r = FakeT3R(trace)
rig = FakeAlignRig(stage, t3r, ref_mm=ref_mm, **rig_kwargs)
scope = FakeAlignScope(trace, rig, acquisitions_advance=acquisitions_advance)
aligner = AutoAligner(stage, scope, t3r, settings=settings,
should_abort=should_abort)
return aligner, rig, trace, stage, t3r
def moves(trace, ch=None):
return [c for c in trace.of("t3r_move") if ch is None or c[1] == ch]
# ── Geometry: the half that cannot be discovered at run time ─────────────────
def test_tilt_groups_are_the_moves_they_claim_to_be():
"""X tilts along X only, Y along Y only — otherwise the phases interfere.
If this fails, the azimuth map and the groups have drifted apart and the
Y phase would be undoing the X phase's correction.
"""
x_piston, x_x, x_y = tilt_response(X_TILT)
y_piston, y_x, y_y = tilt_response(Y_TILT)
assert x_x != 0 and x_y == pytest.approx(0.0, abs=1e-9)
assert y_y != 0 and y_x == pytest.approx(0.0, abs=1e-9)
# The Y pair is equal and opposite, so it lifts nothing on average; the
# single X axis unavoidably lifts the platform as well as tilting it.
assert y_piston == pytest.approx(0.0, abs=1e-9)
assert x_piston != 0
def test_x_is_corrected_by_the_axis_lying_along_x():
"""T1 sits at 0°, so it is the one that tilts the platform along X."""
assert T_AXIS_AZIMUTH_DEG[1] == 0.0
assert set(X_TILT.weights) == {1}
def test_y_is_corrected_by_the_other_two_as_an_opposed_pair():
assert set(Y_TILT.weights) == {0, 2}
assert Y_TILT.weights[0] == -Y_TILT.weights[2]
# ── The loop does what it is for ─────────────────────────────────────────────
def test_alignment_cancels_the_sample_slope_on_both_axes():
"""The point of the whole procedure: a level sample when it finishes."""
aligner, rig, _, _, _ = build()
aligner.prepare()
result = aligner.run()
slope_x, slope_y = rig.slopes_mv_per_mm()
# Residual slope over the +/-1.5 mm the scan cares about, in mV.
assert abs(slope_x * DEFAULT_ALIGN.offset_mm) <= DEFAULT_ALIGN.tolerance_mv
assert abs(slope_y * DEFAULT_ALIGN.offset_mm) <= DEFAULT_ALIGN.tolerance_mv
assert result.ok
def test_every_search_ends_inside_the_tolerance():
aligner, _, _, _, _ = build()
reference = aligner.prepare()
result = aligner.run()
for axis in result.axes:
for offset in axis.offsets:
assert offset.converged, offset.describe()
assert offset.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
assert result.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
def test_a_flat_sample_gives_the_same_answer_in_both_directions():
"""Both offsets measure one angle, so on a plane they must agree.
The agreement is what licenses averaging them; see the curved case below
for what happens when it does not hold.
"""
aligner, _, _, _, _ = build()
aligner.prepare()
result = aligner.run()
for axis in result.axes:
plus, minus = axis.offsets
assert plus.correction_steps == pytest.approx(minus.correction_steps,
rel=0.02, abs=5.0)
assert axis.disagreement_steps < 10.0
assert axis.applied
def test_a_curved_sample_is_reported_rather_than_averaged_away():
"""Curvature needs opposite corrections either side, and says so."""
# Big enough that the near side is still outside the tolerance once the
# far side's error has been curved past it — otherwise one search has
# nothing to do and the disagreement never shows up.
aligner, _, _, _, _ = build(curvature_mv_per_mm2=60.0)
aligner.prepare()
result = aligner.run()
x_axis = result.axes[0]
plus, minus = x_axis.offsets
assert plus.correction_steps * minus.correction_steps < 0 # opposite signs
assert x_axis.disagreement_steps > 100.0
def test_the_search_survives_a_noisy_detector():
"""Five reads and a median, so a wobbling level still converges."""
aligner, _, _, _, _ = build(jitter_mv=1.5)
reference = aligner.prepare()
result = aligner.run()
assert result.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
# ── Which hardware moves, and how ───────────────────────────────────────────
def test_the_x_phase_moves_t1_and_the_y_phase_moves_t0_and_t2():
"""The phases stay on their own axes, in the order X then Y."""
aligner, _, trace, _, t3r = build()
aligner.prepare()
aligner.run()
channels = [c[1] for c in moves(trace)]
first_y = next(i for i, ch in enumerate(channels) if ch in (0, 2))
assert set(channels[:first_y]) == {1}, "the X phase moved something else"
assert set(channels[first_y:]) == {0, 2}, "the Y phase moved something else"
# The Y pair ends equal and opposite: anything else is a tilt along X the
# Y phase had no business applying.
assert t3r.positions[0] == -t3r.positions[2]
assert t3r.positions[1] != 0
def test_the_rotation_axis_is_never_touched():
"""GR carries the scan's angle; an alignment that moved it would silently
re-datum every subsequent scan."""
aligner, _, trace, _, t3r = build()
aligner.prepare()
aligner.run()
assert moves(trace, ch=3) == []
assert t3r.positions[3] == 0
assert [c for c in trace.of("t3r_enable") if c[1] == 3] == []
def test_the_t_axes_are_configured_before_they_are_moved():
"""32 microsteps and 600 mA, applied rather than assumed — a correction is
reported in microsteps, so what a microstep means has to be pinned."""
aligner, _, trace, _, _ = build()
aligner.prepare()
for ch in (0, 1, 2):
assert ("t3r_set_microstep", ch, 32) in trace.calls
run_ma = [c for c in trace.of("t3r_set_current") if c[1] == ch]
assert run_ma and run_ma[0][2] == 600
assert ("t3r_enable", ch) in trace.calls
names = trace.names()
assert "t3r_move" not in names[:names.index("t3r_enable")]
def test_the_stage_steps_either_side_and_comes_back():
aligner, _, trace, stage, _ = build()
aligner.prepare()
aligner.run()
aligner.stop()
x_targets = [c[2] for c in trace.of("move_axis_absolute") if c[1] == AXIS_X]
y_targets = [c[2] for c in trace.of("move_axis_absolute") if c[1] == AXIS_Y]
off = DEFAULT_ALIGN.offset_mm
assert REF_MM[0] + off in x_targets and REF_MM[0] - off in x_targets
assert REF_MM[1] + off in y_targets and REF_MM[1] - off in y_targets
assert stage.positions == list(REF_MM)
def test_the_scope_is_put_into_the_bias_reading_state():
"""The same free-running, edge-triggered state the angle inspector uses:
the operator has to be able to read CH1 while this runs."""
aligner, _, trace, _, _ = build()
aligner.prepare()
scales = {c[1]: c[2] for c in trace.of("set_channel_scale")}
for ch in BIAS_CHANNELS:
assert scales[ch] == BIAS_SCALE_V_DIV
assert ("set_trigger_level", 2, INSPECT_TRIG_LEVEL_V) in trace.calls
assert ("set_fastframe_state", False) in trace.calls
assert "ACQuire:STATE RUN" in [c[1] for c in trace.of("write")]
# Only the two bias channels are ever measured.
assert {c[1] for c in trace.of("measure_immediate")} == set(BIAS_CHANNELS)
def test_the_gate_is_dropped_before_anything_moves():
"""An armed TRIGOUT would drive the scan gate on every positioning move."""
aligner, _, trace, _, _ = build()
aligner.prepare()
assert ("set_trigger_gate_off", AXIS_X) in trace.calls
# ── Refusals ────────────────────────────────────────────────────────────────
def test_a_dead_axis_stops_the_procedure():
"""No response to a probe, however large: something is wrong upstream of
the tilt platform, and stepping the actuators further will not find it."""
aligner, _, _, _, _ = build(tilt_gain_mv_per_mm=0.0)
aligner.prepare()
with pytest.raises(AutoAlignError, match="laser"):
aligner.run()
def test_a_scope_that_never_retriggers_stops_the_procedure():
"""A stale record reads as a rock-steady measurement — the one failure the
loop cannot see for itself."""
aligner, _, _, _, _ = build(acquisitions_advance=False)
with pytest.raises(AutoAlignError, match="not triggered"):
aligner.prepare()
def test_an_axis_that_would_run_out_of_travel_stops_the_procedure():
aligner, _, _, _, _ = build(x_slope_mv_per_mm=200.0,
tilt_gain_mv_per_mm=0.01)
aligner.prepare()
with pytest.raises(AutoAlignError, match="safety limit"):
aligner.run()
def test_there_has_to_be_room_either_side_of_the_reference_point():
aligner, _, _, _, _ = build(ref_mm=(0.5, 40.0))
with pytest.raises(AutoAlignError, match="either side"):
aligner.prepare()
def test_run_before_the_operator_confirms_is_refused():
aligner, _, _, _, _ = build()
with pytest.raises(AutoAlignError, match="prepare"):
aligner.run()
def test_missing_hardware_is_named():
trace = Trace()
stage = FakeStage(trace)
t3r = FakeT3R(trace, is_open=False)
rig = FakeAlignRig(stage, t3r)
scope = FakeAlignScope(trace, rig)
with pytest.raises(AutoAlignError, match="T3R"):
AutoAligner(stage, scope, t3r, settings=FAST).prepare()
with pytest.raises(AutoAlignError, match="Oscilloscope"):
AutoAligner(stage, None, t3r, settings=FAST).prepare()
with pytest.raises(AutoAlignError, match="BBD202"):
AutoAligner(None, scope, t3r, settings=FAST).prepare()
def test_an_abort_stops_the_run_and_still_parks_the_stage():
"""Stopping is the operator's, so it must not leave the stage 1.5 mm off
the point they were looking at."""
calls = {"n": 0}
def abort_after_a_few_moves():
calls["n"] += 1
return calls["n"] > 12
aligner, _, _, stage, _ = build(should_abort=abort_after_a_few_moves)
aligner.prepare()
with pytest.raises(AutoAlignAborted):
aligner.run()
aligner.stop()
assert stage.positions == list(REF_MM)
def test_stop_leaves_the_correction_applied():
"""The tilt is the result — a stop parks the stage, not the platform."""
aligner, _, _, _, t3r = build()
aligner.prepare()
aligner.run()
applied = dict(t3r.positions)
aligner.stop()
assert t3r.positions == applied
+45
View File
@@ -38,6 +38,51 @@ def test_sc3_aui_main_window(qapp):
_pump(qapp) _pump(qapp)
def test_camera_window_without_hardware_is_a_plain_viewer(qapp):
"""No stage, no scope, no auto-align button to press."""
import sc3_aui_app
win = sc3_aui_app.CameraWindow()
_pump(qapp)
try:
assert not win.uc480_auto_align_btn.isVisible()
finally:
win.deleteLater()
_pump(qapp)
def test_camera_window_auto_align_needs_every_device(qapp):
"""The button appears once the three devices exist, and says which one is
missing rather than starting and failing on the rig."""
import sc3_aui_app
from gui.qt_t3r import QtT3RAdapter
win = sc3_aui_app.CameraWindow(QtT3RAdapter(), sc3_aui_app.BBD202Worker(),
sc3_aui_app.OscopeWorker())
_pump(qapp)
try:
assert win.uc480_auto_align_btn.isVisibleTo(win)
assert "oscilloscope" in win._align_prerequisite_problem()
finally:
win.deleteLater()
_pump(qapp)
def test_auto_align_window_logs_a_result(qapp):
import sc3_aui_app
from core.auto_align import AlignResult, Reading
win = sc3_aui_app.AutoAlignWindow()
_pump(qapp)
try:
reference = Reading(400.0, 400.0)
win.set_reference(reference)
win.on_reading(Reading(403.0, 397.0))
win.on_finished(AlignResult(reference=reference, final=reference))
assert "Reference" in win.log.toPlainText()
assert win.close_btn.isEnabled()
finally:
win.deleteLater()
_pump(qapp)
def test_sras_viewer_window(qapp): def test_sras_viewer_window(qapp):
import sras_viewer import sras_viewer
win = sras_viewer.SrasViewerWindow() win = sras_viewer.SrasViewerWindow()