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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user