Auto-align: level the sample on the DC bias levels from the camera window

The operator frames a good spot, confirms the two DC levels the detector
reads there, and the rig then measures its own tilt: step 1.5 mm either side
on X and then on Y, and tilt the platform until those levels come back.  The
correction that fixes an offset point is the correction that levels the whole
travel — height error and tilt effect are both proportional to the offset —
so the procedure ends by applying it and leaving it applied.

Both directions are measured from the same starting tilt and averaged, which
makes their disagreement a flatness read-out rather than something averaged
away silently.

core/auto_align.py holds the geometry and the search, Qt-free.  The three
T-axes' azimuths are the whole geometry: T1 lies along +X so it alone tilts
along X, and T0/T2 move as an equal-and-opposite pair to tilt along Y without
touching X (tilt_response derives that, and the tests pin it — an axis map
that drifts would still converge, on the wrong axis).  The search is a secant
null on the split-detector difference: probe once to learn what a microstep
is worth, sign included, then step at the null.  It refuses to servo on a
scope that has not re-triggered, escalates a probe that reads as no response
before calling an axis dead, and stops at a per-axis travel limit.

gui/align_bridge.py runs it on a worker thread; stopping is a threading.Event
rather than a queued command, because the worker is inside a long handler for
the whole run.  The camera window carries the button and the progress window,
and locks the scan panel and the jog pads while a run owns the stage.

Adds immediate MEAN measurements and an acquisition count to the scope
driver, and read_bias_mv to core/scope_inspect — the one scalar the
inspection state was missing.

KNOWN_ISSUES.md records what only the rig can settle: the probe step, the
travel limit, the hold current, and whether the piston the X phase applies
alongside its tilt matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-09-04 14:00:36 -05:00
parent 083cbdaa34
commit 6e8c1cb7a2
13 changed files with 1868 additions and 19 deletions
+66
View File
@@ -344,6 +344,72 @@ class TektronixOscilloscopeBase:
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 ==========
def set_data_source(self, source):