debug: refuse to read a mechanism out of loop checkpoints
CI for csaxs_bec / test (push) Successful in 1m49s

Run against scan 450, the trace reported a flyback contaminating every
line. It is wrong, and the file says why: samy is recorded three times
per line, at 198.8163 / 198.8651 / 198.9238, and those are the same
three positions in line 0, line 20 and line 43. They sit in the bottom
47% of the range and run backwards. They are scan-loop checkpoints taken
after the burst and during the return -- the traverse itself is never
sampled. Describing the return move as if it were the line is the wrong
answer delivered confidently, which is worse than no answer.

Two guards. Per line, if the samples never advance along the commanded
direction, say so and name no mechanism. Globally, report what fraction
of the commanded travel the readback covers and how much of it runs the
right way, and stop before the span, stall and step-size numbers if the
answer is none -- those describe the loop, not the stage.

Repetition across lines is deliberately not the test. A healthy scan
repeats too, since every line does the same thing; an earlier version
tested for that and flagged all four synthetic controls.

Also rank the leading channel by range over counting noise rather than
range over level. On scan 450 the old score picked mca8, which sits at
2 counts and is pure Poisson, over mca6, which is the diode that formed
the image and carries the artefact.
This commit is contained in:
menzel committed 2026-08-25 20:31:27 +02:00
1 parent 796652c278
commit 78f012f955
1 file changed
+64 -3
+64 -3
View File
@@ -478,7 +478,32 @@ def rank_fast_traces(cands: dict, slow_name, slow_breaks) -> list:
return scored
def check_line_motion_from_motor(signals, motors, meta) -> None:
def readback_coverage(segs, travel_cmd: float, direction: float):
"""(coverage, forward), each as a fraction of the commanded travel.
coverage -- how much of the line the samples span at all.
forward -- how far they get in the direction the line was commanded to run.
Repetition across lines cannot be the test for "is this a trace of the
motion": a healthy continuous scan repeats too, because every line does the
same thing. What separates a trace from a handful of loop checkpoints is
whether the samples follow the line at all.
"""
if travel_cmd <= 0 or not direction:
return None, None
cov, fwd = [], []
for sg in segs:
if sg.size < 2:
continue
cov.append(float(np.ptp(sg)) / travel_cmd)
cov_adv = direction * (sg - sg[0])
fwd.append(float(cov_adv.max()) / travel_cmd)
if not cov:
return None, None
return float(np.median(cov)), float(np.median(fwd))
def check_line_motion_from_motor(signals, motors, meta, req=None) -> None:
"""Bug 2, using a real positioner trace rather than whatever looks rampy."""
print("\n BUG 2 -- did the fast axis move on every line?")
cands = motor_candidates(signals, motors)
@@ -522,6 +547,24 @@ def check_line_motion_from_motor(signals, motors, meta) -> None:
ratio = spans / median
print(f" samples per line : ~{per_line} median line span : {median:.6g}")
fs, fe = (req or {}).get("fast_start"), (req or {}).get("fast_end")
if isinstance(fs, (int, float)) and isinstance(fe, (int, float)) and fe != fs:
cov, fwd = readback_coverage(segs, abs(fe - fs), np.sign(fe - fs))
if cov is not None:
print(f" readback covers {cov:.0%} of the commanded travel,"
f" {fwd:.0%} of it in the commanded direction")
if fwd < 0.1:
print("\n STOP: the readback never advances along the commanded")
print(" direction. It is sampled at fixed points in the scan loop --")
print(" after the burst and during the return -- and not while the")
print(" line runs. The spans, stalls and step sizes below are")
print(" properties of the loop, not of the stage, and none of them is")
print(" evidence about the motion.\n")
elif cov < 0.6:
print(f"\n CAUTION: the readback spans only {cov:.0%} of the line, so"
" it misses")
print(" part of every traverse. The travel below is understated.\n")
# Enough geometry to check this file against the scan that was intended.
# A step size that disagrees with the scan command means either the wrong
# axis has been identified or the motion was not what was asked for -- and
@@ -685,7 +728,11 @@ def _trace_grid_data(signals, motors, grid, nominal, index) -> None:
bulk = float(np.median(np.abs(row)))
if not np.isfinite(bulk) or bulk <= 0:
continue
ranked.append((float(np.ptp(row)) / bulk, name, row))
# Range in units of counting noise, not of level. A channel sitting at
# 2 counts has an enormous *relative* range and no information in it;
# ranking by ptp/median floats that Poisson noise to the top and buries
# the diode that actually formed the image.
ranked.append((float(np.ptp(row)) / max(np.sqrt(bulk), 1.0), name, row))
if not ranked:
print("\n no per-point data on the scan grid for this line.")
return
@@ -784,6 +831,20 @@ def _trace_position(signals, times, motors, meta, nominal, v_cmd, index, per_lin
stop_i = int(np.argmax(adv)) # first sample at maximum travel
point_step = travel_cmd / max(1, per_line - 1)
# If nothing ever advances, these samples are not a trace of the traverse and
# nothing about the traverse can be read out of them. Saying so is the whole
# point: the alternative is to describe the return move as if it were the
# line, which is exactly the wrong answer confidently delivered.
if float(adv.max()) <= 0.1 * travel_cmd:
print(f" NOT A TRACE OF THE LINE. The samples span"
f" {float(seg.min()):.6f} .. {float(seg.max()):.6f}"
f" -- {float(np.ptp(seg)) / travel_cmd:.0%} of the commanded travel, and")
print(" none of it in the commanded direction. They are taken at fixed")
print(" points in the scan loop -- after the burst and during the return --")
print(" not while the line runs. No mechanism can be named from this file;")
print(" it needs a faster monitored readout or a measurement on the motor.")
return
if t is not None and stop_i > 0 and t[stop_i] > 0:
v_ach = adv[stop_i] / t[stop_i]
line = f" velocity while moving : {v_ach:.6g}/s"
@@ -982,7 +1043,7 @@ def analyse(path: str, do_list: bool, force_lines=None, force_points=None,
# motor position does not -- it is sampled while the line runs, at whatever
# rate the readout loop managed, so it has its own length and its own
# line boundaries.
check_line_motion_from_motor(signals, motors, meta)
check_line_motion_from_motor(signals, motors, meta, read_request_inputs(h5))
check_end_of_line(signals, grid, mon, None)
if trace_lines:
times = numeric_timestamps(h5)