From 78f012f955be2e19b2a23b5cb41e923b0ff8e898 Mon Sep 17 00:00:00 2001 From: menzel Date: Tue, 25 Aug 2026 20:31:27 +0200 Subject: [PATCH] debug: refuse to read a mechanism out of loop checkpoints 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. --- bin/debug/scan_artefact_check.py | 67 ++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/bin/debug/scan_artefact_check.py b/bin/debug/scan_artefact_check.py index f80b5267..cf95ba61 100755 --- a/bin/debug/scan_artefact_check.py +++ b/bin/debug/scan_artefact_check.py @@ -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)