From c6387d6bd7885de6522ebb9d3f27246b585f2b25 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Thu, 20 Aug 2026 17:35:45 -0700 Subject: [PATCH 01/19] docs(tuning): scaffold the auto-tune explainer page --- MANIFEST.in | 1 + docs/explainers/auto_tune.html | 98 ++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 docs/explainers/auto_tune.html diff --git a/MANIFEST.in b/MANIFEST.in index b1d665a5..8769923b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include *.md recursive-include docs *.md +recursive-include docs *.html include LICENSE include pyproject.toml diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html new file mode 100644 index 00000000..476ffcbb --- /dev/null +++ b/docs/explainers/auto_tune.html @@ -0,0 +1,98 @@ + + + + + +How auto-tune works — SC Linac + + + +
+

How auto-tune works

+

+ Every frequency-tuning path in sc_linac_physics — auto setup, + the tuning GUI, and RF commissioning — converges through one loop: + Cavity._auto_tune(). This page explains what that loop + commands the hardware to do, lets you drive a simulated copy of it, and + lists how it fails. +

+ +

1. What tuning moves, and with what

+

2. The loop

+

3. Where the detune number comes from

+

4. Tune states

+

5. The commissioning stages

+

6. How it fails

+
+ + + From 883c92182ccba2e749afa2230fbc484dd6c98419 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Thu, 20 Aug 2026 17:37:51 -0700 Subject: [PATCH 02/19] docs(tuning): port stepper_tol_factor to the explainer with a self-check --- docs/explainers/auto_tune.html | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 476ffcbb..77b5bb6e 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -93,6 +93,66 @@

How auto-tune works

From fe8c1cde5b05fd8efb53420fb5d3662b02b0922d Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Thu, 20 Aug 2026 17:58:56 -0700 Subject: [PATCH 03/19] docs(tuning): address review findings on the tol factor port --- docs/explainers/auto_tune.html | 127 ++++++++++++++++++++----- tests/docs/__init__.py | 0 tests/docs/test_auto_tune_explainer.py | 49 ++++++++++ 3 files changed, 151 insertions(+), 25 deletions(-) create mode 100644 tests/docs/__init__.py create mode 100644 tests/docs/test_auto_tune_explainer.py diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 77b5bb6e..44297f18 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -11,6 +11,10 @@ --ink: #e6e8ec; --dim: #9aa3b2; --line: #2b303a; + /* Interactive-control border. --line is only 1.25:1 against --panel, which + leaves buttons with no visible edge until hover. --edge is 3.25:1 + against --panel (WCAG non-text contrast minimum is 3:1). */ + --edge: #656f7e; --accent: #6ea8fe; --warn: #e0a458; --bad: #e06c75; @@ -59,10 +63,13 @@ vertical-align: top; } th { color: var(--dim); font-weight: 600; } + /* Wrapper for tables too wide for the viewport, so they scroll on their own + instead of forcing the whole page sideways. */ + .scroll { overflow-x: auto; } button { background: var(--panel); color: var(--ink); - border: 1px solid var(--line); + border: 1px solid var(--edge); border-radius: 5px; padding: .35rem .8rem; font: inherit; @@ -70,6 +77,9 @@ cursor: pointer; } button:hover { border-color: var(--accent); } + /* This rule sets the CSS (layout) size only. The simulator task must also set + the backing store to width*devicePixelRatio / height*devicePixelRatio and + ctx.scale(dpr, dpr), or the canvas renders blurry on HiDPI displays. */ canvas { width: 100%; height: auto; display: block; margin: 1rem 0 0; } @@ -84,33 +94,69 @@

How auto-tune works

lists how it fails.

-

1. What tuning moves, and with what

-

2. The loop

-

3. Where the detune number comes from

-

4. Tune states

-

5. The commissioning stages

-

6. How it fails

+

1. What tuning moves, and with what

+

2. The loop

+

3. Where the detune number comes from

+

4. Tune states

+

5. How it fails

diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/docs/test_auto_tune_explainer.py b/tests/docs/test_auto_tune_explainer.py new file mode 100644 index 00000000..1a35c995 --- /dev/null +++ b/tests/docs/test_auto_tune_explainer.py @@ -0,0 +1,49 @@ +"""Guard the hand-copied constants in docs/explainers/auto_tune.html. + +The explainer restates stepper_tol_factor's outputs in JavaScript so the page +works offline. Its in-page selfCheck() only proves the page agrees with itself. +These tests re-derive every oracle row from the real Python function, so a +change to linac_utils.py fails here instead of silently making the page lie. +""" + +import re +from pathlib import Path + +import pytest + +from sc_linac_physics.utils.sc_linac.linac_utils import stepper_tol_factor + +EXPLAINER = ( + Path(__file__).resolve().parents[2] + / "docs" + / "explainers" + / "auto_tune.html" +) +HTML = EXPLAINER.read_text() + + +def _oracle_rows(): + """Pull the [num_steps, expected] pairs out of the page's TOL_ORACLE.""" + block = re.search(r"const TOL_ORACLE = \[(.*?)\n\];", HTML, re.S) + assert block, "TOL_ORACLE not found in the explainer" + pairs = re.findall(r"\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]", block.group(1)) + return [(int(n), float(want)) for n, want in pairs] + + +def test_oracle_row_count(): + assert len(_oracle_rows()) == 14 + + +@pytest.mark.parametrize("num_steps,expected", _oracle_rows()) +def test_oracle_matches_python(num_steps, expected): + assert stepper_tol_factor(num_steps) == pytest.approx(expected, abs=1e-5) + + +def test_no_inner_html(): + """A repo hook rejects innerHTML; the page builds DOM via createElement.""" + assert "innerHTML" not in HTML + + +def test_no_external_references(): + """The page is opened offline on control-room machines.""" + assert not re.search(r"https?://", HTML) From 3192b07512bd00d35cbc611e794fa8580b3951e0 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 11:47:44 -0700 Subject: [PATCH 04/19] docs(tuning): port the _auto_tune loop with hand-traced convergence checks --- docs/explainers/auto_tune.html | 261 ++++++++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 1 deletion(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 44297f18..aac28691 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -138,6 +138,93 @@

How auto-tune works

[-50000, 3.888889], ]; +// Traced from Cavity._auto_tune (cavity.py:825-906) against a Python +// reference that reuses the real stepper_tol_factor. Every count below was +// produced by that trace, not by hand arithmetic — see the note on case 1 for +// why hand arithmetic gets this wrong. +// +// All five cases share a 500 Hz start, a 50 Hz tolerance, and the nominal +// 1.4/256 Hz-per-microstep scale, which gives 182.857 microsteps/Hz. So +// expected_steps = int(500 * 182.857) = 91,428, the tol factor at that count +// is 2.738, and every case gets the same 250,340-microstep budget. The only +// things that vary are the undershoot factor and the calibration error. +const LOOP_ORACLE = [ + { + name: "converges in two moves when calibration is perfect", + input: { + detune0: 500, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.4 / 256, tolerance: 50, undershoot: 0.9, + }, + // TWO moves, not one, and the reason is the truncation. With perfect + // calibration the first move is meant to remove exactly 90% of 500 Hz and + // leave exactly 50.0 Hz, which would NOT be > tolerance -- the loop would + // exit after one move. But est_steps = int(0.9 * 500 * 182.857) = + // int(82285.714) = 82285, and dropping that 0.714 of a microstep removes + // only 449.996 Hz. What is left is 50.0039 Hz, which IS > 50, so the + // while condition holds and a second move runs (8,229 steps, landing at + // 5.0 Hz). + // + // So the hard-coded 0.9 aims a perfectly-tuned cavity at precisely the + // tolerance boundary, and truncation always leaves it a hair outside. + // A well-calibrated cavity at 10x tolerance therefore costs two moves, + // never one. + expect: { outcome: "converged", iterations: 2 }, + }, + { + name: "diverges at undershoot 1.0 with 2.5x calibration error", + input: { + detune0: 500, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 2.5 * 1.4 / 256, tolerance: 50, undershoot: 1.0, + }, + // |1 - 1.0 * 2.5| = 1.5 > 1, so |detune| grows every move: 500 -> -750 + // -> 1125 -> -1687. The budget trips on move 3 at 434,280 steps. + expect: { outcome: "runaway", iterations: 3 }, + }, + { + name: "converges at undershoot 0.9 with 1.8x calibration error", + input: { + detune0: 500, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.8 * 1.4 / 256, tolerance: 50, undershoot: 0.9, + }, + // |1 - 0.9 * 1.8| = 0.62, so each move cuts |detune| to 62% of the + // previous one, alternating sign: 500 -> -310 -> 192 -> -119 -> 74 -> + // -45.8, inside tolerance on move 5 having spent 196,694 of the 250,340 + // available steps. This is the undershoot factor earning its keep -- + // compare the next case, which is the same 1.8x error at undershoot 1.0. + expect: { outcome: "converged", iterations: 5 }, + }, + { + name: "same 1.8x error runs away once undershoot is 1.0", + input: { + detune0: 500, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.8 * 1.4 / 256, tolerance: 50, undershoot: 1.0, + }, + // |1 - 1.0 * 1.8| = 0.8, so it still shrinks, but only 20% per move + // instead of 38%, and each move costs more steps because it is not + // scaled down by 0.9. The budget trips on move 4. Identical hardware + // miscalibration to the case above -- the only difference is the 0.9. + expect: { outcome: "runaway", iterations: 4 }, + }, + { + name: "convergent in principle, still over budget at 2.0x", + input: { + detune0: 500, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 2.0 * 1.4 / 256, tolerance: 50, undershoot: 0.9, + }, + // |1 - 0.9 * 2.0| = 0.8 < 1, so this iteration DOES converge + // mathematically -- but it needs ~11 moves to get 500 Hz under 50 Hz, and + // the cumulative step count is a geometric series summing to roughly + // 411,000 microsteps against a 250,340 budget. The runaway guard trips on + // move 5. + // + // Worth internalizing: "the loop converges" and "the loop is allowed to + // finish" are different questions. The tol factor is a budget measured + // against what a CORRECTLY calibrated cavity would have needed, so a + // badly calibrated one gets cut off even when it is heading the right way. + expect: { outcome: "runaway", iterations: 5 }, + }, +]; + // Port of linac_utils.stepper_tol_factor (linac_utils.py:224). // // How many times the estimated step count the loop is allowed to actually move @@ -177,6 +264,160 @@

How auto-tune works

return 1.01; } +// Port of Cavity._auto_tune (cavity.py:825). +// +// The real loop, per iteration: +// 1. check_abort() +// 2. if max_stepper_temp is set and the stepper is hotter -> StepperTempError +// 3. iteration_callback() (commissioning uses it for abort + the live plot) +// 4. est_steps = int(0.9 * delta_hz * microsteps_per_hz) +// 5. stepper_tuner.move(est_steps, max_steps=|est_steps|*1.1, MAX SPEED) +// 6. if cumulative steps > expected * tol_factor -> DetuneError (runaway) +// 7. check_detune() <- this is where the chirp range gets widened +// 8. delta_hz = delta_hz_func() (re-read from the machine) +// +// The one thing the real loop does NOT do: bound its own iteration count. It +// exits on tolerance, on the step budget, on a temp breach, or on an abort. +// MAX_ITERATIONS here is a simulator guard only, so a divergent setting shows +// as "did not converge" instead of hanging the browser. +const MAX_ITERATIONS = 500; + +function runAutoTune(opts) { + const { + detune0, + scaleHzPerMicrostep, // believed, from the SCALE PV + trueHzPerMicrostep, // actual cavity response + tolerance, + undershoot = 0.9, + fault = null, + faultAtIteration = 2, + } = opts; + + // Cavity.microsteps_per_hz = 1 / StepperTuner.hz_per_microstep, and that + // getter returns abs() of SCALE (stepper.py:96) — the loop only ever sees + // the magnitude. Direction comes from the sign of delta_hz. + const microstepsPerHz = 1 / Math.abs(scaleHzPerMicrostep); + + let deltaHz = detune0; + const expectedSteps = Math.abs(Math.trunc(deltaHz * microstepsPerHz)); + const tolFactor = stepperTolFactor(expectedSteps); + const budget = expectedSteps * tolFactor; + + let stepsMoved = 0; + const iterations = []; + const log = []; + let outcome = "converged"; + let error = null; + // Effective response, degraded by the mechanical-slip fault. + let responseHz = trueHzPerMicrostep; + + while (Math.abs(deltaHz) > tolerance) { + if (iterations.length >= MAX_ITERATIONS) { + outcome = "no-converge"; + error = `Did not converge in ${MAX_ITERATIONS} iterations`; + break; + } + + const i = iterations.length + 1; + const firing = fault && i >= faultAtIteration; + + // check_abort() is the FIRST thing in the real loop body, ahead of the + // temperature guard, so it is first here too. Only one fault fires at a + // time on this page, so the order is not observable — it is matched so + // the page does not teach the wrong precedence. + if (firing && fault === "abort") { + outcome = "abort"; + error = "StepperAbortError: Abort requested"; + log.push("ABORT_REQ written to the stepper; the move in progress " + + "stops. Up to ~10 s from the request (5 s settle + 5 s " + + "poll interval). RF is left on."); + break; + } + + if (firing && fault === "hot_motor") { + outcome = "temp"; + error = "StepperTempError: stepper motor temp 72.4 °C exceeds " + + "limit 70 °C"; + log.push("Temp is read before the move, so this move never starts. " + + "No cool-down, no retry — the caller must intervene."); + break; + } + + if (firing && fault === "slip") { + responseHz = trueHzPerMicrostep * 0.1; + log.push("Mechanical slip: the motor turns but the cavity barely " + + "responds, so the loop keeps asking for more steps."); + } + + const estSteps = Math.trunc(undershoot * deltaHz * microstepsPerHz); + + // int(abs(est_steps) * 1.1), the max_steps argument handed to + // StepperTuner.move(). Recorded for display only, and deliberately not + // applied to the simulated move, because it never limits anything at THIS + // call site: move() splits a request into chunks only when + // abs(num_steps) > max_steps, and 1.1 * abs(est_steps) is always larger + // than abs(est_steps). So the split branch (stepper.py:311) is unreachable + // from _auto_tune, and max_steps only ends up writing the STEP_MAX PV + // limit on the IOC. + const maxStepsClamp = Math.trunc(Math.abs(estSteps) * 1.1); + + // Raised from inside move() (stepper.py:419) once the motor stops. It sits + // here, after est_steps is computed but before this iteration's state is + // recorded, because the real exception propagates out of move() before + // `steps_moved += abs(est_steps)` and before delta_hz is re-read. The + // cavity physically moved; the loop's bookkeeping never learns about it. + if (firing && fault === "limit_switch") { + outcome = "limit"; + error = "StepperError: stepper motor on limit switch"; + log.push("Checked after the move completes — the motor stopped for a " + + "bad reason, not because it arrived."); + break; + } + + const before = deltaHz; + deltaHz = deltaHz - estSteps * responseHz; + stepsMoved += Math.abs(estSteps); + + iterations.push({ + i, deltaHzBefore: before, estSteps, maxStepsClamp, + stepsMoved, deltaHzAfter: deltaHz, + }); + + if (stepsMoved > budget) { + outcome = "runaway"; + error = "DetuneError: motor moved more steps than expected"; + log.push( + `Moved ${stepsMoved.toLocaleString()} microsteps against a budget ` + + `of ${Math.round(budget).toLocaleString()} ` + + `(${expectedSteps.toLocaleString()} expected x ` + + `${tolFactor.toFixed(3)} tol factor).` + ); + break; + } + + if (firing && fault === "detune_invalid_chirp") { + log.push("check_detune(): detune invalid in chirp mode, so " + + "find_chirp_range widens the sweep 1.1x and the loop " + + "carries on."); + } + if (firing && fault === "detune_invalid_sela") { + outcome = "sela-invalid"; + error = "DetuneError: Cannot tune in SELA with invalid detune"; + log.push("No recovery path in SELA — there is no chirp range to widen."); + break; + } + } + + return { + outcome, error, iterations, log, + expectedSteps, tolFactor, budget, stepsMoved, + finalDetuneHz: deltaHz, + // |1 - undershoot * ratio| < 1 is the convergence condition for the + // fixed-point iteration this loop performs. + gain: undershoot * (trueHzPerMicrostep / Math.abs(scaleHzPerMicrostep)), + }; +} + // Puts the failure list where the reader will actually see it. The audience // double-clicks this file and never opens devtools, so a console-only failure // would render identically to a healthy page. @@ -218,10 +459,28 @@

How auto-tune works

failures.push(`stepperTolFactor(${n}) = ${got}, expected ${want}`); } } + for (const c of LOOP_ORACLE) { + const got = runAutoTune(c.input); + if (got.outcome !== c.expect.outcome) { + failures.push( + `${c.name}: outcome ${got.outcome}, expected ${c.expect.outcome}` + ); + } + if (c.expect.iterations !== undefined && + got.iterations.length !== c.expect.iterations) { + failures.push( + `${c.name}: ${got.iterations.length} iterations, ` + + `expected ${c.expect.iterations}` + ); + } + } if (failures.length) { console.error("SELF-CHECK FAILED\n" + failures.join("\n")); } else { - console.log(`SELF-CHECK PASSED (${TOL_ORACLE.length} tol-factor cases)`); + console.log( + `SELF-CHECK PASSED (${TOL_ORACLE.length} tol-factor cases, ` + + `${LOOP_ORACLE.length} loop cases)` + ); } return failures; } From e7ce17935d96df5f094fd5813bf208b019e699ad Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 12:16:43 -0700 Subject: [PATCH 05/19] docs(tuning): correct the loop port's oracle coverage and labelling --- docs/explainers/auto_tune.html | 362 +++++++++++++++++++++++++++++---- 1 file changed, 321 insertions(+), 41 deletions(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index aac28691..260bb2b5 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -143,11 +143,29 @@

How auto-tune works

// produced by that trace, not by hand arithmetic — see the note on case 1 for // why hand arithmetic gets this wrong. // -// All five cases share a 500 Hz start, a 50 Hz tolerance, and the nominal -// 1.4/256 Hz-per-microstep scale, which gives 182.857 microsteps/Hz. So -// expected_steps = int(500 * 182.857) = 91,428, the tol factor at that count -// is 2.738, and every case gets the same 250,340-microstep budget. The only -// things that vary are the undershoot factor and the calibration error. +// Unless stated otherwise a case uses the nominal 1.4/256 Hz-per-microstep +// scale (182.857 microsteps/Hz) and a 50 Hz tolerance, and varies only the +// starting detune, the undershoot factor, and the calibration error. +// +// THE HEADROOM RULE. This is the most portable thing on the page. Write +// gain = undershoot * (true scale / believed scale) and q = |1 - gain|. Each +// move multiplies the remaining detune by q, so the loop's total travel is a +// geometric series: +// +// total travel / expected_steps = undershoot / (1 - q) +// +// The left side is what the runaway guard measures, and the right side has no +// detune in it at all — it is scale-free. But the budget it is measured +// against, the tol factor, SHRINKS as the starting detune grows: 2.738 at +// 500 Hz (91,428 expected steps), only 1.369 at 5000 Hz (914,285). So the +// same calibration error that is comfortably survivable from 500 Hz is fatal +// from 5000 Hz. Concretely, at 5000 Hz the loop tolerates a true/believed +// scale ratio up to about 1.49 at undershoot 0.9, but only about 1.27 at +// undershoot 1.0 — verified numerically, the flip happens between 1.49/1.50 +// and 1.27/1.30 respectively. +// +// Read that backwards and it is the operational lesson: the further out of +// tune the cavity starts, the better your stepper calibration has to be. const LOOP_ORACLE = [ { name: "converges in two moves when calibration is perfect", @@ -168,7 +186,30 @@

How auto-tune works

// tolerance boundary, and truncation always leaves it a hair outside. // A well-calibrated cavity at 10x tolerance therefore costs two moves, // never one. - expect: { outcome: "converged", iterations: 2 }, + expect: { outcome: "converged", moves: 2 }, + }, + { + name: "already inside tolerance, so the loop never runs", + input: { + detune0: 10, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.4 / 256, tolerance: 50, undershoot: 0.9, + }, + // `while abs(delta_hz) > tolerance` is checked before the first move, so a + // cavity already in tolerance is a no-op: zero moves, zero steps, and the + // stepper is never commanded at all. Worth pinning because Task 4's UI has + // to render an empty move list without falling over. + expect: { outcome: "converged", moves: 0 }, + }, + { + name: "negative detune is symmetric", + input: { + detune0: -500, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.4 / 256, tolerance: 50, undershoot: 0.9, + }, + // Direction lives in the sign of delta_hz, never in the scale (the + // hz_per_microstep getter takes abs()). int() truncates toward zero on + // both sides, so -500 Hz mirrors +500 Hz exactly: 2 moves, same counts. + expect: { outcome: "converged", moves: 2 }, }, { name: "diverges at undershoot 1.0 with 2.5x calibration error", @@ -178,7 +219,8 @@

How auto-tune works

}, // |1 - 1.0 * 2.5| = 1.5 > 1, so |detune| grows every move: 500 -> -750 // -> 1125 -> -1687. The budget trips on move 3 at 434,280 steps. - expect: { outcome: "runaway", iterations: 3 }, + // gain = 2.5 sits outside the 0 < gain < 2 convergence window entirely. + expect: { outcome: "runaway", moves: 3 }, }, { name: "converges at undershoot 0.9 with 1.8x calibration error", @@ -191,7 +233,7 @@

How auto-tune works

// -45.8, inside tolerance on move 5 having spent 196,694 of the 250,340 // available steps. This is the undershoot factor earning its keep -- // compare the next case, which is the same 1.8x error at undershoot 1.0. - expect: { outcome: "converged", iterations: 5 }, + expect: { outcome: "converged", moves: 5 }, }, { name: "same 1.8x error runs away once undershoot is 1.0", @@ -203,7 +245,10 @@

How auto-tune works

// instead of 38%, and each move costs more steps because it is not // scaled down by 0.9. The budget trips on move 4. Identical hardware // miscalibration to the case above -- the only difference is the 0.9. - expect: { outcome: "runaway", iterations: 4 }, + // + // By the headroom rule: 1.0/(1-0.8) = 5.0x expected steps wanted, against + // a 2.738x budget. The case above wanted 0.9/(1-0.62) = 2.37x, which fits. + expect: { outcome: "runaway", moves: 4 }, }, { name: "convergent in principle, still over budget at 2.0x", @@ -212,19 +257,147 @@

How auto-tune works

trueHzPerMicrostep: 2.0 * 1.4 / 256, tolerance: 50, undershoot: 0.9, }, // |1 - 0.9 * 2.0| = 0.8 < 1, so this iteration DOES converge - // mathematically -- but it needs ~11 moves to get 500 Hz under 50 Hz, and - // the cumulative step count is a geometric series summing to roughly - // 411,000 microsteps against a 250,340 budget. The runaway guard trips on - // move 5. + // mathematically -- but it needs 11 moves to bring 500 Hz under 50 Hz, + // travelling about 376,000 microsteps to do it, against a 250,340 budget. + // (The headroom rule's 0.9/(1-0.8) = 4.5x, or ~411,000 steps, is the + // infinite sum; the loop stops at tolerance, so it would have spent + // somewhat less than that. Either way it is over budget.) The runaway + // guard trips on move 5. // // Worth internalizing: "the loop converges" and "the loop is allowed to // finish" are different questions. The tol factor is a budget measured // against what a CORRECTLY calibrated cavity would have needed, so a // badly calibrated one gets cut off even when it is heading the right way. - expect: { outcome: "runaway", iterations: 5 }, + // + // Note this case has the SAME loop gain as the one above -- 1.0 x 1.8 and + // 0.9 x 2.0 are both 1.8 -- so both trace an identical detune trajectory + // in Hz (500 -> -400 -> 320 -> ...). Kept as a pair on purpose: it + // isolates step cost from trajectory. Same path, but the 0.9 case buys + // each step of progress 10% cheaper, which is exactly why it survives to + // move 5 instead of dying on move 4. + expect: { outcome: "runaway", moves: 5 }, + }, + + // ---- The 5000 Hz regime, where the tol factor is only 1.369 ---- + // Task 4's UI opens at detune0 = 5000, so these pin the defaults a reader + // will actually see. expected_steps = int(5000 * 182.857) = 914,285, the tol + // factor is 1.36905, and the budget is 1,251,701 microsteps. + { + name: "5000 Hz start needs three moves even when calibration is perfect", + input: { + detune0: 5000, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.4 / 256, tolerance: 50, undershoot: 0.9, + }, + // Three, where 500 Hz took two: undershoot 0.9 removes a fixed FRACTION + // per move, so the number of moves scales with how many factors of 10 + // separate the starting detune from the tolerance, not with the detune. + expect: { outcome: "converged", moves: 3 }, + }, + { + name: "2.0x error is fatal from 5000 Hz even at undershoot 0.9", + input: { + detune0: 5000, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 2.0 * 1.4 / 256, tolerance: 50, undershoot: 0.9, + }, + // The same 2.0x error and the same undershoot as the case above this + // block, which survived to move 5 from 500 Hz. From 5000 Hz it trips on + // move 2, because the budget multiplier has halved from 2.738 to 1.369 + // while the travel it needs (4.5x expected steps) has not changed at all. + // This is the headroom rule at work, and it is the regime the UI defaults + // to. + expect: { outcome: "runaway", moves: 2 }, + }, + { + name: "1.35x error converges from 5000 Hz at undershoot 0.9", + input: { + detune0: 5000, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.35 * 1.4 / 256, tolerance: 50, undershoot: 0.9, + }, + // gain = 1.215, q = 0.215, so travel wants 0.9/0.785 = 1.146x expected + // steps against the 1.369x budget -- it fits, with room to spare. Lands + // at -49.7 Hz on move 3 having spent 1,037,807 steps. 1.35 was chosen + // because it sits cleanly inside the undershoot-0.9 window (which closes + // between 1.49 and 1.50) and cleanly outside the undershoot-1.0 window + // (which closes between 1.27 and 1.30) -- see the next case. + expect: { outcome: "converged", moves: 3 }, + }, + { + name: "the same 1.35x error runs away at undershoot 1.0", + input: { + detune0: 5000, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.35 * 1.4 / 256, tolerance: 50, undershoot: 1.0, + }, + // gain = 1.35, q = 0.35, travel wants 1.0/0.65 = 1.538x against the same + // 1.369x budget, and it trips on move 3 at 1,346,283 steps. This pair is + // the cleanest statement of what the hard-coded 0.9 buys: identical + // hardware, identical miscalibration, and the only difference between + // converging and a DetuneError is that one factor. + expect: { outcome: "runaway", moves: 3 }, }, + { + name: "est_steps truncating to zero never terminates", + input: { + // A believed scale of 100 Hz/microstep is wildly wrong (nominal is + // 0.0055), but it is the cleanest way to reach this state. + detune0: 60, scaleHzPerMicrostep: 100, + trueHzPerMicrostep: 100, tolerance: 50, undershoot: 0.9, + }, + // THIS CASE DOCUMENTS A REAL BUG IN THE PYTHON LOOP, not a quirk of the + // simulator. microsteps_per_hz = 0.01, so + // est_steps = int(0.9 * 60 * 0.01) = int(0.54) = 0. + // + // A zero-step move changes nothing: delta_hz is re-read as the same + // 60 Hz, which is still > 50, so the loop goes around again. And because + // steps_moved += abs(0) never grows, the runaway guard can never fire -- + // expected_steps is 0 here too, so the budget is 0 and `0 > 0` is false + // forever. Nothing bounds the iteration count. In the real Python this + // spins indefinitely, doing nothing, until something outside it aborts. + // + // The precondition is a believed scale above 0.9 * tolerance + // (45 Hz/microstep at the default 50 Hz tolerance), i.e. a SCALE PV about + // four orders of magnitude too large. So it needs a badly wrong + // calibration to reach -- but nothing in the loop stops it. + // + // MAX_ITERATIONS is what turns it into a reported outcome here. Filed + // separately; deliberately not fixed in the Python from this page. + expect: { outcome: "no-converge" }, + }, +]; + +// The six fault injections, pinned because Task 4's UI is built directly on +// them. All six run against a perfectly calibrated 5000 Hz start (3 moves +// clean) and fire at move 2, so the expected outcome is entirely down to the +// fault. `moves` counts recorded moves, which is one less than faultIteration +// for the faults that break before the move completes. +const FAULT_ORACLE = [ + // Breaks at the top of move 2, before any stepping. + { fault: "abort", outcome: "abort", moves: 1 }, + { fault: "hot_motor", outcome: "temp", moves: 1 }, + // Breaks after the move is commanded but before it is recorded. + { fault: "limit_switch", outcome: "limit", moves: 1 }, + // Degrades the response to 10% from move 2 on, so gain collapses to 0.09 + // and the travel the loop needs (0.9/0.91 ~ 10x expected steps) blows + // through the 1.369x budget. Move 1 lands normally at 500 Hz; from there + // each move removes only 9% of what is left, grinding 500 -> 258 Hz over + // seven more moves while burning ~46k-82k steps each, and tripping the + // budget on move 8 at 1,264,673 steps. The instructive part is that it + // looks like it is working -- the detune really is falling -- and the guard + // still stops it, because it is falling far too slowly per step spent. + { fault: "slip", outcome: "runaway", moves: 8 }, + // Recoverable: widens the chirp range and carries on to a normal finish. + { fault: "detune_invalid_chirp", outcome: "converged", moves: 3 }, + // Checked at the END of the loop body, so move 2 is completed and recorded + // before this breaks -- hence 2 moves, not 1. + { fault: "detune_invalid_sela", outcome: "sela-invalid", moves: 2 }, ]; +// Shared input for FAULT_ORACLE, kept separate so the faults vary one thing. +const FAULT_BASE = { + detune0: 5000, scaleHzPerMicrostep: 1.4 / 256, + trueHzPerMicrostep: 1.4 / 256, tolerance: 50, undershoot: 0.9, + faultAtIteration: 2, +}; + // Port of linac_utils.stepper_tol_factor (linac_utils.py:224). // // How many times the estimated step count the loop is allowed to actually move @@ -276,10 +449,30 @@

How auto-tune works

// 7. check_detune() <- this is where the chirp range gets widened // 8. delta_hz = delta_hz_func() (re-read from the machine) // +// Step 7 is not the whole story, and the simplification is worth naming. +// _auto_tune calls move() without check_detune, which defaults to True +// (stepper.py:253), and move()'s motor-moving poll loop calls +// cavity.check_detune() every ~5 s while the motor is turning +// (stepper.py:387-391). So the chirp range can be widened SEVERAL TIMES +// during a single move, not just once per iteration as modelled here. +// +// Also not modelled: the pre-loop `if self.detune_invalid` guard +// (cavity.py:833), which raises DetuneError before the first move ever +// happens. Deliberate — this port starts from a valid detune reading, and the +// detune_invalid_* faults below cover the in-loop check_detune() path instead. +// // The one thing the real loop does NOT do: bound its own iteration count. It -// exits on tolerance, on the step budget, on a temp breach, or on an abort. -// MAX_ITERATIONS here is a simulator guard only, so a divergent setting shows -// as "did not converge" instead of hanging the browser. +// exits on tolerance, on the step budget, on a temp breach, or on an abort — +// and none of those is guaranteed to happen. +// +// MAX_ITERATIONS is a simulator guard, and it is NOT there to catch divergence: +// a divergent setting grows |delta_hz| every move, so it blows the step budget +// within a handful of moves and reports "runaway" on its own. The state that +// actually reaches MAX_ITERATIONS is est_steps truncating to 0 while +// |delta_hz| is still outside tolerance. Then the motor never moves, delta_hz +// never changes, and steps_moved never grows, so the runaway guard can never +// fire either. In the real Python that is an unbounded hang. See the +// "est_steps truncating to zero" case in LOOP_ORACLE. const MAX_ITERATIONS = 500; function runAutoTune(opts) { @@ -308,6 +501,10 @@

How auto-tune works

const log = []; let outcome = "converged"; let error = null; + // Which move the fault landed on. Needed because the abort, temp and + // limit-switch paths break before pushing their iteration record, so + // iterations.length is one short of the move that actually failed. + let faultIteration = null; // Effective response, degraded by the mechanical-slip fault. let responseHz = trueHzPerMicrostep; @@ -320,17 +517,33 @@

How auto-tune works

const i = iterations.length + 1; const firing = fault && i >= faultAtIteration; + if (firing && faultIteration === null) faultIteration = i; // check_abort() is the FIRST thing in the real loop body, ahead of the // temperature guard, so it is first here too. Only one fault fires at a // time on this page, so the order is not observable — it is matched so // the page does not teach the wrong precedence. + // + // There are TWO abort paths and they behave differently. This models the + // loop-top one: Cavity.check_abort (cavity.py:1054), reached at + // cavity.py:849 between moves, which calls turn_off() and raises + // CavityAbortError. No move is in flight, so it stops promptly — and it + // TURNS RF OFF. + // + // The other path is StepperTuner.check_abort (stepper.py:129), reached + // from inside move()'s poll loop. That one writes ABORT_REQ to stop a move + // already in progress and raises StepperAbortError, taking up to ~10 s to + // land (5 s start-up settle + 5 s poll interval) and leaving RF on. That + // is where the "~10 s, RF stays on" behaviour belongs; it is not what this + // fault does. if (firing && fault === "abort") { outcome = "abort"; - error = "StepperAbortError: Abort requested"; - log.push("ABORT_REQ written to the stepper; the move in progress " + - "stops. Up to ~10 s from the request (5 s settle + 5 s " + - "poll interval). RF is left on."); + error = "CavityAbortError: Abort requested"; + log.push("Loop-top Cavity.check_abort(), between moves, so there is no " + + "move to interrupt. It calls turn_off() first: RF ends up OFF, " + + "not just untuned. (An abort landing mid-move instead goes " + + "through the stepper's ABORT_REQ path, which takes up to ~10 s " + + "and leaves RF on.)"); break; } @@ -345,21 +558,27 @@

How auto-tune works

if (firing && fault === "slip") { responseHz = trueHzPerMicrostep * 0.1; - log.push("Mechanical slip: the motor turns but the cavity barely " + - "responds, so the loop keeps asking for more steps."); + // Annotated once, on the move where it starts. The degraded response + // persists for every later move, but repeating the note per move would + // just fill Task 4's log panel with identical lines. + if (i === faultAtIteration) { + log.push("Mechanical slip: the motor turns but the cavity barely " + + "responds, so the loop keeps asking for more steps."); + } } const estSteps = Math.trunc(undershoot * deltaHz * microstepsPerHz); // int(abs(est_steps) * 1.1), the max_steps argument handed to - // StepperTuner.move(). Recorded for display only, and deliberately not - // applied to the simulated move, because it never limits anything at THIS + // StepperTuner.move(). Recorded for display only, and deliberately NOT + // applied to the simulated move, because it never limits anything at this // call site: move() splits a request into chunks only when // abs(num_steps) > max_steps, and 1.1 * abs(est_steps) is always larger // than abs(est_steps). So the split branch (stepper.py:311) is unreachable - // from _auto_tune, and max_steps only ends up writing the STEP_MAX PV - // limit on the IOC. - const maxStepsClamp = Math.trunc(Math.abs(estSteps) * 1.1); + // from _auto_tune, and the argument's only real effect is writing the + // stepper's NSTEPS.DRVH drive-high limit on the IOC (stepper.py:41, setter + // at stepper.py:216). + const maxStepsArg = Math.trunc(Math.abs(estSteps) * 1.1); // Raised from inside move() (stepper.py:419) once the motor stops. It sits // here, after est_steps is computed but before this iteration's state is @@ -379,7 +598,7 @@

How auto-tune works

stepsMoved += Math.abs(estSteps); iterations.push({ - i, deltaHzBefore: before, estSteps, maxStepsClamp, + i, deltaHzBefore: before, estSteps, maxStepsArg, stepsMoved, deltaHzAfter: deltaHz, }); @@ -395,10 +614,13 @@

How auto-tune works

break; } - if (firing && fault === "detune_invalid_chirp") { + // Annotated once, on the move where it starts, for the same reason as the + // slip note above. + if (firing && fault === "detune_invalid_chirp" && i === faultAtIteration) { log.push("check_detune(): detune invalid in chirp mode, so " + "find_chirp_range widens the sweep 1.1x and the loop " + - "carries on."); + "carries on. In the real loop this can happen repeatedly " + + "WITHIN one move, not just once after it."); } if (firing && fault === "detune_invalid_sela") { outcome = "sela-invalid"; @@ -411,9 +633,38 @@

How auto-tune works

return { outcome, error, iterations, log, expectedSteps, tolFactor, budget, stepsMoved, + faultIteration, + + // The loop's own view of the detune, i.e. what delta_hz holds when it + // exits. On "converged", "runaway" and "no-converge" this is post-move + // truth: the runaway guard fires AFTER delta_hz has been updated, so the + // reported value deliberately includes the move that broke the budget. + // + // On "limit" it is stale by a full move — the simulated cavity moved but + // the exception propagates out of move() before delta_hz is re-read, so + // the loop never learns the new value. On "abort" and "temp" nothing moved, + // so it is current. On "sela-invalid" it is a number, but the entire + // meaning of that state is that the machine's detune reading is INVALID, + // so the value should be presented as unavailable rather than precise. finalDetuneHz: deltaHz, - // |1 - undershoot * ratio| < 1 is the convergence condition for the - // fixed-point iteration this loop performs. + detuneIsStale: outcome === "limit", + + // Loop gain. Each move multiplies the remaining detune by (1 - gain), so + // the fixed-point iteration converges exactly when |1 - gain| < 1, i.e. + // + // 0 < gain < 2 + // + // Spell that out wherever this is displayed: gain = 1 is a perfect + // single-move landing, and gain = 1.8 is still INSIDE the window despite + // being greater than 1 — it converges, just slowly and by overshooting + // and alternating sign. gain >= 2 (or <= 0) is what actually diverges. + // Converging is necessary but not sufficient; the step budget can still + // cut a slow convergence off, which is what the headroom rule above is + // about. + // + // CAVEAT: computed from trueHzPerMicrostep, so with the `slip` fault + // active this describes only the moves BEFORE the slip. The trajectory + // actually drawn after that point runs at a tenth of this gain. gain: undershoot * (trueHzPerMicrostep / Math.abs(scaleHzPerMicrostep)), }; } @@ -466,28 +717,57 @@

How auto-tune works

`${c.name}: outcome ${got.outcome}, expected ${c.expect.outcome}` ); } - if (c.expect.iterations !== undefined && - got.iterations.length !== c.expect.iterations) { + if (c.expect.moves !== undefined && + got.iterations.length !== c.expect.moves) { + failures.push( + `${c.name}: ${got.iterations.length} moves, ` + + `expected ${c.expect.moves}` + ); + } + } + for (const c of FAULT_ORACLE) { + const got = runAutoTune({ ...FAULT_BASE, fault: c.fault }); + if (got.outcome !== c.outcome) { failures.push( - `${c.name}: ${got.iterations.length} iterations, ` + - `expected ${c.expect.iterations}` + `fault ${c.fault}: outcome ${got.outcome}, expected ${c.outcome}` ); } + if (got.iterations.length !== c.moves) { + failures.push( + `fault ${c.fault}: ${got.iterations.length} moves, ` + + `expected ${c.moves}` + ); + } + if (got.faultIteration !== FAULT_BASE.faultAtIteration) { + failures.push( + `fault ${c.fault}: fired on move ${got.faultIteration}, ` + + `expected ${FAULT_BASE.faultAtIteration}` + ); + } + // Every fault must say something, or Task 4 renders an empty panel. + if (!got.log.length) { + failures.push(`fault ${c.fault}: produced no log lines`); + } + // The annotation-only faults are the ones that could duplicate, since + // they do not break out of the loop. + if (new Set(got.log).size !== got.log.length) { + failures.push(`fault ${c.fault}: duplicate log lines`); + } } if (failures.length) { console.error("SELF-CHECK FAILED\n" + failures.join("\n")); } else { console.log( `SELF-CHECK PASSED (${TOL_ORACLE.length} tol-factor cases, ` + - `${LOOP_ORACLE.length} loop cases)` + `${LOOP_ORACLE.length} loop cases, ${FAULT_ORACLE.length} fault cases)` ); } return failures; } -const tolFailures = selfCheck(); -if (tolFailures.length) { - showFailureBanner(tolFailures); +const failures = selfCheck(); +if (failures.length) { + showFailureBanner(failures); } From 2656d0c8e2cb3faa8f549f77abd2cdc9ffaa8fd7 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 12:36:45 -0700 Subject: [PATCH 06/19] docs(tuning): add the interactive convergence simulator --- docs/explainers/auto_tune.html | 684 ++++++++++++++++++++++++++++++++- 1 file changed, 683 insertions(+), 1 deletion(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 260bb2b5..f8ec0f08 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -81,6 +81,51 @@ the backing store to width*devicePixelRatio / height*devicePixelRatio and ctx.scale(dpr, dpr), or the canvas renders blurry on HiDPI displays. */ canvas { width: 100%; height: auto; display: block; margin: 1rem 0 0; } + + /* ---- Section 2 simulator ---- */ + /* label / control / live value. Collapses to one column on narrow screens. */ + .ctl { + display: grid; + /* Third column has to hold "0.0055 Hz/microstep" without clipping. */ + grid-template-columns: 15rem 1fr 11rem; + gap: .3rem .9rem; + align-items: center; + } + .ctl > label { font-size: .92rem; } + .ctl > .val { + font-family: var(--mono); + font-size: .85rem; + text-align: right; + white-space: nowrap; + } + /* Notes sit under their own row, spanning the whole grid. */ + .ctl > .note { grid-column: 1 / -1; margin: -.2rem 0 .5rem; } + @media (max-width: 44rem) { + .ctl { grid-template-columns: 1fr; gap: .15rem; } + .ctl > .val { text-align: left; } + .ctl > .note { margin: 0 0 .75rem; } + } + input[type="range"] { width: 100%; margin: 0; accent-color: var(--accent); } + select { + background: var(--panel); + color: var(--ink); + border: 1px solid var(--edge); + border-radius: 5px; + padding: .25rem .4rem; + font: inherit; + font-size: .85rem; + max-width: 100%; + } + .bar { + height: .8rem; + border: 1px solid var(--edge); + border-radius: 4px; + background: var(--bg); + overflow: hidden; + margin: .4rem 0 .3rem; + } + .bar > div { height: 100%; } + .verdict { font-weight: 600; margin: 1rem 0 .25rem; } @@ -95,7 +140,123 @@

How auto-tune works

1. What tuning moves, and with what

-

2. The loop

+
+

2. The loop

+ +

+ Read the detune, convert Hz to microsteps, move most of the way, read + again, repeat until you are inside tolerance. That is the entire + algorithm. Everything else in _auto_tune is a guard against + it going wrong. +

+ +
+
+delta_hz = delta_hz_func()                      # read the machine
+expected_steps = |delta_hz * microsteps_per_hz|
+tol_factor     = stepper_tol_factor(expected_steps)
+tune_config    = OTHER                          # "mid-tune, do not trust me"
+
+while |delta_hz| > tolerance:
+    check_abort()
+    if stepper_temp > max_stepper_temp:  raise StepperTempError
+    iteration_callback()                        # abort flag + live plot
+    est_steps = int(0.9 * delta_hz * microsteps_per_hz)
+    stepper_tuner.move(est_steps,
+                       max_steps = |est_steps| * 1.1,
+                       speed     = MAX_STEPPER_SPEED)
+    if steps_moved > expected_steps * tol_factor:  raise DetuneError
+    check_detune()                              # may widen the chirp range
+    delta_hz = delta_hz_func()                  # read the machine again
+

cavity.py:825-906

+
+ +

Drive it

+ +
+
+

+ + + +

+ +
+
+
+
+ + + + + + + +
#Δf before (Hz)est_stepsmax_steps argcumulativeΔf after (Hz)
+
+
+ +

Two things the trace will not tell you

+ +

+ Truncation means a perfect cavity lands a hair outside + tolerance. est_steps is an int(...), + and 0.9 leaves a tenth of the detune behind — so a perfectly calibrated + cavity starting at exactly ten times tolerance is aimed precisely at the + tolerance boundary, and the truncated step always drops it just outside. + Set the starting + detune to 500 Hz with tolerance 50 and calibration error 1.0: the + exact aim is 81,818.18 microsteps, which would leave exactly + 50.000 Hz and end the loop. int() hands the motor + 81,818 instead, and the 0.18 of a microstep it drops leaves + 50.0010 Hz — still > 50, so a second move runs. At + the nominal 1.4/256 Hz-per-microstep scale the same arithmetic leaves + 50.0039 Hz. A + well-calibrated cavity at ten times tolerance therefore costs two moves, + never one. +

+ +

+ Converging is not the same as being allowed to finish, and the + headroom shrinks as detune grows. Each move multiplies the + remaining detune by |1 - gain|, so the loop's total travel is + a geometric series — and it is scale-free: +

+ +
+
travel / expectedSteps = undershoot / (1 - |1 - gain|)     <- no detune in it
+budget / expectedSteps = stepper_tol_factor(expectedSteps)  <- shrinks as detune grows
+
+ +

+ The travel a given miscalibration demands does not care how far out of + tune you started. The budget does. At the page defaults, 500 Hz is + 90,909 expected steps and buys a 2.75× budget; 5000 Hz is + 909,090 steps and buys only 1.376×. (At the nominal 1.4/256 scale + those same two figures are 2.738× and 1.369×.) So the further + out of tune a cavity starts, the less calibration error the loop + tolerates. From 5000 Hz, undershoot 0.9 survives a true/believed + scale ratio up to about 1.49× and undershoot 1.0 only to about + 1.27×. That is what the 0.9 is really buying: budget headroom, not + just mathematical stability. From 5000 Hz a gain of 1.8 converges in + principle — |1 - 1.8| < 1 — and still trips the runaway + guard at every undershoot the slider offers. +

+ +

+ Check it above: at the 5000 Hz default, calibration error 1.35 + converges at undershoot 0.9 and runs away at 1.0. Same hardware, same + miscalibration; the only difference is that one factor. +

+ +
+ What this simulator is not. The modelled cavity responds + linearly and without noise. Real cavities have mechanical hysteresis and + dead zones — which is exactly why stepper_tol_factor() allows + 5× the estimated steps below 10,000 steps and only 1.01× near + cold landing. Trust the loop structure here; do not trust the smoothness. +
+

3. Where the detune number comes from

4. Tune states

5. How it fails

@@ -765,10 +926,531 @@

How auto-tune works

return failures; } +// --------------------------------------------------------------------------- +// Section 2: the simulator UI. Everything below is presentation on top of +// runAutoTune() — no arithmetic of its own, so the page cannot disagree with +// the port it is displaying. +// --------------------------------------------------------------------------- + +// Minimal element builder. Deliberately not HTML-string assignment — a repo +// hook rejects that, tests/docs/test_auto_tune_explainer.py greps for it, and +// building nodes keeps the rendering code below shorter anyway. +function el(tag, props, children) { + const node = document.createElement(tag); + if (props) { + for (const key of Object.keys(props)) { + const val = props[key]; + if (key === "class") node.className = val; + else if (key === "text") node.textContent = val; + else if (key === "style") node.style.cssText = val; + else node.setAttribute(key, val); + } + } + for (const child of children || []) { + node.appendChild( + typeof child === "string" ? document.createTextNode(child) : child + ); + } + return node; +} + +function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); +} + +// Plot colours are read back out of the stylesheet rather than hard-coded a +// second time, so the canvas can never drift from the rest of the page. +function cssVar(name) { + const value = + getComputedStyle(document.documentElement).getPropertyValue(name); + return value.trim() || "#888888"; +} + +const SLIDERS = [ + { id: "detune0", label: "Starting detune", unit: "Hz", + min: -50000, max: 50000, step: 100, value: 5000 }, + { id: "tolerance", label: "Tolerance", unit: "Hz", + min: 5, max: 1000, step: 5, value: 50, + note: "50 Hz standard, 500 Hz for harmonic linearizers (cavity.py:735)" }, + // step is 0.0005, not 0.001, because a range input snaps an off-grid value + // onto the step grid: with step 0.001 the 0.0055 default would land on + // 0.005 or 0.006 and the page would open on a scale no cavity has. + { id: "scaleHz", label: "SCALE (believed)", unit: "Hz/microstep", + min: 0.001, max: 0.1, step: 0.0005, value: 0.0055, + note: "Per-cavity, measured, read from the SCALE PV — not a constant" }, + { id: "calibError", label: "Calibration error (true / believed)", unit: "x", + min: 0.2, max: 3, step: 0.05, value: 1, + note: "1.0 means SCALE is exactly right" }, + { id: "undershoot", label: "Undershoot factor", unit: "", + min: 0.5, max: 1.3, step: 0.05, value: 0.9, + note: "Hard-coded 0.9 in the real loop (cavity.py:868)" }, +]; + +// Option text for the fault picker. Keyed off FAULT_ORACLE so a fault added to +// the port shows up here even without a label, rather than silently vanishing +// from the UI. +const FAULT_LABELS = { + abort: "abort — check_abort() between moves", + hot_motor: "hot_motor — stepper over temperature", + limit_switch: "limit_switch — motor stopped on a limit", + slip: "slip — motor turns, cavity barely responds", + detune_invalid_chirp: "detune_invalid_chirp — recoverable, widens the chirp", + detune_invalid_sela: "detune_invalid_sela — fatal, no chirp to widen", +}; + +// Outcome -> what a human should read, and which of the three status colours +// it earns. Aborts are --warn: nothing broke, someone asked it to stop. +const OUTCOMES = { + converged: { label: "Converged — inside tolerance", color: "--ok" }, + runaway: { + label: "DetuneError — the runaway guard tripped", color: "--bad", + }, + temp: { label: "StepperTempError — motor too hot to move", color: "--bad" }, + limit: { label: "StepperError — motor stopped on a limit switch", + color: "--bad" }, + "sela-invalid": { label: "DetuneError — invalid detune in SELA", + color: "--bad" }, + "no-converge": { label: "Never terminated — see the truncation case", + color: "--bad" }, + abort: { label: "CavityAbortError — aborted, and RF is now off", + color: "--warn" }, +}; + +// How many completed moves the display reveals. Infinity means "all of them"; +// "Step once" walks it up from 0 so a reader can watch one move at a time. +let shownMoves = Infinity; +let activeFault = null; + +const inputs = {}; +let faultSelect = null; + +function sliderDecimals(step) { + return (String(step).split(".")[1] || "").length; +} + +function fmtSlider(spec, value) { + const dec = sliderDecimals(spec.step); + const text = dec ? value.toFixed(dec) : String(value); + return spec.unit ? text + " " + spec.unit : text; +} + +// Detunes are printed to 4 decimals below 100 Hz on purpose: the whole point of +// the truncation lesson is the 50.0010 Hz that "50.0" would hide. +function fmtHz(value) { + const magnitude = Math.abs(value); + if (magnitude >= 1000) return value.toFixed(1); + if (magnitude >= 100) return value.toFixed(2); + return value.toFixed(4); +} + +function buildControls(host) { + const grid = el("div", { class: "ctl" }); + + for (const spec of SLIDERS) { + const input = el("input", { + type: "range", id: spec.id, min: spec.min, max: spec.max, + step: spec.step, value: spec.value, + }); + const readout = el("span", { + class: "val", id: spec.id + "-val", + text: fmtSlider(spec, spec.value), + }); + input.addEventListener("input", render); + inputs[spec.id] = input; + + grid.appendChild(el("label", { for: spec.id, text: spec.label })); + grid.appendChild(input); + grid.appendChild(readout); + if (spec.note) { + grid.appendChild(el("div", { class: "cite note", text: spec.note })); + } + } + + faultSelect = el("select", { id: "fault" }, [ + el("option", { value: "", text: "none" }), + ]); + for (const entry of FAULT_ORACLE) { + faultSelect.appendChild(el("option", { + value: entry.fault, + text: FAULT_LABELS[entry.fault] || entry.fault, + })); + } + faultSelect.addEventListener("change", function () { + setFault(faultSelect.value || null); + }); + + grid.appendChild(el("label", { for: "fault", text: "Injected fault" })); + grid.appendChild(faultSelect); + grid.appendChild(el("span", { class: "val", text: "" })); + grid.appendChild(el("div", { class: "cite note", + text: "Fires on move 2. Section 5 covers what each one means." })); + + host.appendChild(grid); +} + +// Exposed as a function rather than an assignment so section 5 can drive the +// simulator from its own controls without duplicating the reset bookkeeping. +function setFault(fault) { + activeFault = fault; + if (faultSelect) faultSelect.value = fault || ""; + render(); +} + +// The canvas is laid out at width:100%, so its CSS size changes with the +// window. Re-derive the backing store from devicePixelRatio on every render or +// it renders blurry on HiDPI displays and stale after a resize. +function sizeCanvas(cv) { + const dpr = window.devicePixelRatio || 1; + const cssW = cv.clientWidth || 900; + const cssH = Math.round(cssW * 260 / 900); + cv.style.height = cssH + "px"; + cv.width = Math.round(cssW * dpr); + cv.height = Math.round(cssH * dpr); + const ctx = cv.getContext("2d"); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { ctx, W: cssW, H: cssH }; +} + +function drawPlot(values, shown) { + const cv = document.getElementById("plot"); + const { ctx, W, H } = sizeCanvas(cv); + ctx.clearRect(0, 0, W, H); + + // Iteration 0 is the starting detune; every later point is one completed + // move, which is what the loop's delta_hz re-read actually reports. + const points = [values.detune0].concat(shown.map((it) => it.deltaHzAfter)); + const tol = values.tolerance; + + // padB holds two stacked lines: the move-number ticks and the axis caption. + const padL = 62, padR = 104, padT = 20, padB = 46; + const plotW = Math.max(W - padL - padR, 10); + const plotH = Math.max(H - padT - padB, 10); + + let peak = tol; + for (const point of points) peak = Math.max(peak, Math.abs(point)); + const yMax = peak * 1.2 || 1; + const lastIndex = Math.max(points.length - 1, 1); + const X = (i) => padL + (plotW * i) / lastIndex; + const Y = (hz) => padT + plotH / 2 - (hz / yMax) * (plotH / 2); + const crisp = (y) => Math.round(y) + 0.5; + + const dim = cssVar("--dim"); + const lineColor = cssVar("--line"); + const ok = cssVar("--ok"); + const accent = cssVar("--accent"); + + // Tolerance band: inside this, the while condition is false and the loop is + // done. Everything the loop does is an attempt to get into this stripe. + ctx.globalAlpha = 0.16; + ctx.fillStyle = ok; + ctx.fillRect(padL, Y(tol), plotW, Y(-tol) - Y(tol)); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + ctx.strokeStyle = ok; + ctx.setLineDash([4, 4]); + for (const edge of [tol, -tol]) { + ctx.beginPath(); + ctx.moveTo(padL, crisp(Y(edge))); + ctx.lineTo(padL + plotW, crisp(Y(edge))); + ctx.stroke(); + } + ctx.setLineDash([]); + + ctx.strokeStyle = lineColor; + ctx.beginPath(); + ctx.moveTo(padL, crisp(Y(0))); + ctx.lineTo(padL + plotW, crisp(Y(0))); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(crisp(padL), padT); + ctx.lineTo(crisp(padL), padT + plotH); + ctx.stroke(); + + if (points.length > 1) { + ctx.strokeStyle = accent; + ctx.lineWidth = 2; + ctx.beginPath(); + points.forEach((hz, i) => { + if (i === 0) ctx.moveTo(X(i), Y(hz)); + else ctx.lineTo(X(i), Y(hz)); + }); + ctx.stroke(); + } + ctx.fillStyle = accent; + points.forEach((hz, i) => { + ctx.beginPath(); + ctx.arc(X(i), Y(hz), 3.5, 0, Math.PI * 2); + ctx.fill(); + }); + + ctx.font = "12px " + "ui-monospace, Menlo, Consolas, monospace"; + ctx.fillStyle = dim; + + // Peak first, then the zero line, then the tolerance edges, dropping any + // label that would collide with one already drawn. + let peakHz = points[0]; + for (const point of points) { + if (Math.abs(point) > Math.abs(peakHz)) peakHz = point; + } + const drawn = []; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (const [hz, text] of [ + [peakHz, fmtHz(peakHz)], + [0, "0"], + [tol, "+" + tol], + [-tol, "-" + tol], + ]) { + const y = Y(hz); + if (drawn.some((other) => Math.abs(other - y) < 12)) continue; + drawn.push(y); + ctx.fillText(text, padL - 8, y); + } + + ctx.textAlign = "left"; + ctx.fillStyle = ok; + ctx.fillText("tolerance", padL + plotW + 8, Y(0) - 7); + ctx.fillText("+/-" + tol + " Hz", padL + plotW + 8, Y(0) + 8); + + ctx.fillStyle = dim; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const every = Math.ceil(lastIndex / 12); + for (let i = 0; i <= lastIndex && i < points.length; i += every) { + ctx.fillText(String(i), X(i), padT + plotH + 8); + } + // On its own line under the ticks: in the right margin it reads as part of + // the last tick number ("3 move #"). + ctx.textAlign = "center"; + ctx.fillText("move #", padL + plotW / 2, padT + plotH + 24); + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.fillText("delta f (Hz)", 4, padT - 8); +} + +function renderBudget(result, shown) { + const host = document.getElementById("budget"); + clear(host); + + const used = shown.length ? shown[shown.length - 1].stepsMoved : 0; + // Exactly the guard's own condition (steps_moved > expected * tol_factor), + // so the colour cannot say something the loop would not. + const over = result.budget > 0 && used > result.budget; + const fraction = result.budget > 0 + ? Math.min(used / result.budget, 1) + : (used > 0 ? 1 : 0); + + host.appendChild(el("p", { class: "cite", style: "margin:1rem 0 0", + text: "Step budget" })); + host.appendChild(el("div", { class: "bar" }, [ + el("div", { + style: "width:" + (fraction * 100).toFixed(2) + "%;background:var(" + + (over ? "--bad" : "--accent") + ")", + }), + ])); + host.appendChild(el("p", { style: "margin:.15rem 0 0", + text: used.toLocaleString() + " microsteps moved of " + + Math.round(result.budget).toLocaleString() + " allowed" + + (over ? " — over budget, so DetuneError." : "") })); + host.appendChild(el("p", { class: "cite", style: "margin:.15rem 0 0", + text: "budget = " + result.expectedSteps.toLocaleString() + + " expected steps x " + result.tolFactor.toFixed(3) + + " tol factor" + + (result.budget === 0 ? " (zero — nothing to move)" : "") })); +} + +function renderVerdict(values, result, shown, complete) { + const host = document.getElementById("verdict"); + clear(host); + + const total = result.iterations.length; + + if (!complete) { + host.appendChild(el("p", { + class: "verdict", style: "color:var(--dim)", + text: "Mid-run: showing " + shown.length + " of " + total + + " completed moves. Keep stepping, or press Run, for the outcome.", + })); + const at = shown.length + ? shown[shown.length - 1].deltaHzAfter + : values.detune0; + host.appendChild(el("p", { + text: shown.length + ? "delta f after move " + shown.length + ": " + fmtHz(at) + " Hz" + : "delta f before the first move: " + fmtHz(at) + " Hz", + })); + return; + } + + const outcome = OUTCOMES[result.outcome] || + { label: result.outcome, color: "--bad" }; + host.appendChild(el("p", { + class: "verdict", style: "color:var(" + outcome.color + ")", + text: outcome.label, + })); + if (result.error) { + host.appendChild(el("p", { class: "cite", text: result.error })); + } + + // faultIteration, not iterations.length: the abort, temp and limit paths + // break before the move is recorded, so the failing move has no table row. + let moveText = total + (total === 1 ? " move" : " moves") + " recorded"; + if (result.faultIteration !== null) { + moveText += total < result.faultIteration + ? "; the fault hit move " + result.faultIteration + + ", which has no row — the loop broke before that move was recorded" + : "; the fault hit move " + result.faultIteration; + } + host.appendChild(el("p", { text: moveText + "." })); + + if (result.detuneIsStale) { + host.appendChild(el("p", { + text: "Last detune reading: " + fmtHz(result.finalDetuneHz) + + " Hz — but that reading predates the failed move. The cavity " + + "moved; the exception left move() before delta_hz was re-read, " + + "so the loop never learned where it ended up.", + })); + } else if (result.outcome === "sela-invalid") { + host.appendChild(el("p", { + text: "Final delta f: unavailable. An invalid detune reading is the " + + "whole content of this failure, so there is no number to quote.", + })); + } else { + host.appendChild(el("p", { + text: "Final delta f: " + fmtHz(result.finalDetuneHz) + + " Hz against a " + values.tolerance + " Hz tolerance.", + })); + } + + // Print the window, not just the number: "gain 1.8" next to "converges" + // reads as a contradiction until you see that the test is |1 - gain| < 1. + const inWindow = result.gain > 0 && result.gain < 2; + let gainText = "Loop gain = undershoot x (true / believed scale) = " + + result.gain.toFixed(3) + ". Each move multiplies the remaining detune by " + + "|1 - gain|, so the iteration converges exactly when 0 < gain < 2 — this " + + "one is " + (inWindow ? "inside" : "outside") + " that window"; + // The headroom lesson only applies when the plotted gain is the gain that + // actually drove the trajectory, which the slip fault breaks. + if (inWindow && result.outcome === "runaway" && activeFault !== "slip") { + gainText += ", and it still tripped the runaway guard: converging and " + + "being allowed to finish are different questions"; + } else if (!inWindow) { + gainText += ", so |delta f| grows every move"; + } + host.appendChild(el("p", { text: gainText + "." })); + + if (activeFault === "slip") { + host.appendChild(el("p", { class: "cite", + text: "That gain is computed from the undegraded response. The slip " + + "fault cuts the response to a tenth from move 2 on, so the gain " + + "above does not describe the trajectory plotted here.", + })); + } +} + +function renderLog(result, complete) { + const host = document.getElementById("log"); + clear(host); + // Log lines explain how the run ENDED, so showing them mid-step would + // announce a failure the reader has not reached yet. + if (!complete) return; + for (const line of result.log) { + host.appendChild(el("p", { class: "caveat", text: line })); + } +} + +function renderTable(shown) { + const body = document.querySelector("#iters tbody"); + clear(body); + for (const it of shown) { + body.appendChild(el("tr", null, [ + el("td", { class: "mono", text: String(it.i) }), + el("td", { class: "mono", text: fmtHz(it.deltaHzBefore) }), + el("td", { class: "mono", text: it.estSteps.toLocaleString() }), + el("td", { class: "mono", text: it.maxStepsArg.toLocaleString() }), + el("td", { class: "mono", text: it.stepsMoved.toLocaleString() }), + el("td", { class: "mono", text: fmtHz(it.deltaHzAfter) }), + ])); + } +} + +function currentValues() { + const values = {}; + for (const spec of SLIDERS) { + values[spec.id] = Number(inputs[spec.id].value); + } + return values; +} + +function currentResult(values) { + return runAutoTune({ + detune0: values.detune0, + tolerance: values.tolerance, + // The believed scale drives the arithmetic; the true one drives the + // cavity. Their ratio is the whole subject of this section. + scaleHzPerMicrostep: values.scaleHz, + trueHzPerMicrostep: values.scaleHz * values.calibError, + undershoot: values.undershoot, + fault: activeFault, + faultAtIteration: 2, + }); +} + +function render() { + const values = currentValues(); + for (const spec of SLIDERS) { + const readout = document.getElementById(spec.id + "-val"); + if (readout) readout.textContent = fmtSlider(spec, values[spec.id]); + } + + const result = currentResult(values); + const total = result.iterations.length; + const shown = result.iterations.slice(0, Math.min(shownMoves, total)); + const complete = shown.length === total; + + drawPlot(values, shown); + renderBudget(result, shown); + renderVerdict(values, result, shown, complete); + renderLog(result, complete); + renderTable(shown); + + return result; +} + +function mountSimulator() { + const host = document.getElementById("controls"); + if (!host) return; + buildControls(host); + + document.getElementById("run").addEventListener("click", function () { + shownMoves = Infinity; + render(); + }); + document.getElementById("step").addEventListener("click", function () { + // From "all shown" there is nothing left to reveal, so min() pins it. + const total = currentResult(currentValues()).iterations.length; + const from = shownMoves === Infinity ? total : shownMoves; + shownMoves = Math.min(from + 1, total); + render(); + }); + document.getElementById("reset").addEventListener("click", function () { + shownMoves = 0; + activeFault = null; + if (faultSelect) faultSelect.value = ""; + render(); + }); + + // The canvas is sized from its CSS width, which changes with the window. + window.addEventListener("resize", render); + + render(); +} + const failures = selfCheck(); if (failures.length) { showFailureBanner(failures); } +mountSimulator(); From 11a3b88da866b48b3656b3a7a44325d0a1001975 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 12:47:51 -0700 Subject: [PATCH 07/19] docs(tuning): derive the section 2 prose figures at runtime --- docs/explainers/auto_tune.html | 204 ++++++++++++++++++++++--- tests/docs/test_auto_tune_explainer.py | 10 +- 2 files changed, 190 insertions(+), 24 deletions(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index f8ec0f08..85e30066 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -204,14 +204,17 @@

Two things the trace will not tell you

and 0.9 leaves a tenth of the detune behind — so a perfectly calibrated cavity starting at exactly ten times tolerance is aimed precisely at the tolerance boundary, and the truncated step always drops it just outside. - Set the starting - detune to 500 Hz with tolerance 50 and calibration error 1.0: the - exact aim is 81,818.18 microsteps, which would leave exactly - 50.000 Hz and end the loop. int() hands the motor - 81,818 instead, and the 0.18 of a microstep it drops leaves - 50.0010 Hz — still > 50, so a second move runs. At - the nominal 1.4/256 Hz-per-microstep scale the same arithmetic leaves - 50.0039 Hz. A + Set the starting detune to  Hz + with tolerance 50 and calibration error 1.0: the exact aim is + microsteps, which would leave the detune + sitting exactly on the tolerance and end the loop. int() + hands the motor instead, and the + of a microstep it drops leaves +  Hz — still > 50, so a + second move runs. At the nominal + HZ_PER_STEP / MICROSTEPS_PER_STEP scale of 1.4/256 + (linac_utils.py:145-147) the same arithmetic leaves +  Hz. A well-calibrated cavity at ten times tolerance therefore costs two moves, never one.

@@ -230,23 +233,32 @@

Two things the trace will not tell you

The travel a given miscalibration demands does not care how far out of - tune you started. The budget does. At the page defaults, 500 Hz is - 90,909 expected steps and buys a 2.75× budget; 5000 Hz is - 909,090 steps and buys only 1.376×. (At the nominal 1.4/256 scale - those same two figures are 2.738× and 1.369×.) So the further + tune you started. The budget does. At the page defaults, +  Hz is + expected steps and buys a + × budget, while the +  Hz the page opens on is + steps and buys only + ×. (At the nominal 1.4/256 + scale those same two figures are + × and + ×.) So the further out of tune a cavity starts, the less calibration error the loop - tolerates. From 5000 Hz, undershoot 0.9 survives a true/believed - scale ratio up to about 1.49× and undershoot 1.0 only to about - 1.27×. That is what the 0.9 is really buying: budget headroom, not - just mathematical stability. From 5000 Hz a gain of 1.8 converges in - principle — |1 - 1.8| < 1 — and still trips the runaway - guard at every undershoot the slider offers. + tolerates. From that opening detune, undershoot 0.9 survives a + true/believed scale ratio up to about + × and undershoot 1.0 only to + about ×. That is what the 0.9 is + really buying: budget headroom, not just mathematical stability. From the + same detune a gain of 1.8 converges in principle — + |1 - 1.8| < 1 — and still trips the runaway guard at + every undershoot the slider offers.

- Check it above: at the 5000 Hz default, calibration error 1.35 - converges at undershoot 0.9 and runs away at 1.0. Same hardware, same - miscalibration; the only difference is that one factor. + Check it above: at the opening detune, calibration error 1.35 converges + at undershoot 0.9 and runs away at 1.0 — it sits inside the first window + and outside the second. Same hardware, same miscalibration; the only + difference is that one factor.

@@ -1043,6 +1055,147 @@

Two things the trace will not tell you

return value.toFixed(4); } +// ---- Figures quoted in this section's prose -------------------------------- +// Anything in the prose that is a CONSEQUENCE of the port, rather than a +// constant lifted from the Python, is written into the page from the shipped +// functions below. That way the words cannot drift from the code by +// construction, and no test has to police them. +// +// Sourced constants stay as literals with their citations — the 50 Hz +// tolerance, the 0.9, the 1.1, 1.4/256. Those are facts about the source, not +// arithmetic this page performs. + +// HZ_PER_STEP / MICROSTEPS_PER_STEP (linac_utils.py:145-147): what a 1.3 GHz +// cavity's SCALE is nominally expected to be, before the per-cavity +// measurement lands in the PV. +const NOMINAL_HZ_PER_MICROSTEP = 1.4 / 256; + +// The truncation example is stated as "ten times tolerance", so the 10 lives +// here and the prose reads the resulting detune back out of it. +const EXAMPLE_TOLERANCE_MULTIPLE = 10; + +// Resolution of the convergence-window scan. The prose says "about"; this is +// what "about" means. +const RATIO_SCAN_STEP = 0.01; + +// Read defaults out of SLIDERS rather than re-typing them, so the prose and +// the controls cannot disagree about what "the page defaults" are. +function sliderDefault(id) { + for (const spec of SLIDERS) { + if (spec.id === id) return spec.value; + } + throw new Error("no slider named " + id); +} + +// A perfectly calibrated run: believed scale is the true scale. +function perfectRun(detune0, hzPerMicrostep) { + return runAutoTune({ + detune0: detune0, + scaleHzPerMicrostep: hzPerMicrostep, + trueHzPerMicrostep: hzPerMicrostep, + tolerance: sliderDefault("tolerance"), + undershoot: sliderDefault("undershoot"), + }); +} + +// The largest true/believed scale ratio that still converges from the detune +// the page opens on — the same experiment as dragging the calibration slider, +// just at finer resolution. Returns null if even a perfect scale fails, which +// would mean something far more basic is broken. +function lastConvergingRatio(undershoot) { + const scale = sliderDefault("scaleHz"); + const detune0 = sliderDefault("detune0"); + const tolerance = sliderDefault("tolerance"); + let last = null; + for (let k = 0; ; k++) { + const ratio = 1 + k * RATIO_SCAN_STEP; + // No wider than the calibration slider itself goes. + if (ratio > 3) break; + const result = runAutoTune({ + detune0: detune0, + scaleHzPerMicrostep: scale, + trueHzPerMicrostep: scale * ratio, + tolerance: tolerance, + undershoot: undershoot, + }); + if (result.outcome !== "converged") break; + last = ratio; + } + return last; +} + +function derivedFigures() { + const scale = sliderDefault("scaleHz"); + const tolerance = sliderDefault("tolerance"); + const undershoot = sliderDefault("undershoot"); + const example = EXAMPLE_TOLERANCE_MULTIPLE * tolerance; + const opened = sliderDefault("detune0"); + + const exampleRun = perfectRun(example, scale); + const exampleNominal = perfectRun(example, NOMINAL_HZ_PER_MICROSTEP); + const openedRun = perfectRun(opened, scale); + const openedNominal = perfectRun(opened, NOMINAL_HZ_PER_MICROSTEP); + + const exactAim = undershoot * example / scale; + const firstMove = exampleRun.iterations[0]; + + const windowAtDefault = lastConvergingRatio(undershoot); + // 1 is "no undershoot at all", the comparison the paragraph is built on. + const windowAtUnity = lastConvergingRatio(1); + if (windowAtDefault === null || windowAtUnity === null) { + throw new Error("no calibration ratio converges"); + } + + const steps = (n) => Math.round(n).toLocaleString(); + // Three decimals, the same precision the budget readout prints, so the prose + // and the simulator quote the tol factor identically. + const factor = (f) => f.toFixed(3); + + return { + "fig-example-detune": String(example), + "fig-example-detune-b": String(example), + // Two decimals because the whole point is the fraction int() throws away; + // one would hide it. + "fig-exact-aim": exactAim.toLocaleString(undefined, + { minimumFractionDigits: 2, maximumFractionDigits: 2 }), + "fig-truncated-aim": firstMove.estSteps.toLocaleString(), + "fig-dropped-microsteps": (exactAim - Math.trunc(exactAim)).toFixed(2), + "fig-residual": fmtHz(firstMove.deltaHzAfter), + "fig-residual-nominal": fmtHz(exampleNominal.iterations[0].deltaHzAfter), + "fig-example-expected": steps(exampleRun.expectedSteps), + "fig-example-tol-factor": factor(exampleRun.tolFactor), + "fig-example-tol-factor-nominal": factor(exampleNominal.tolFactor), + "fig-opened-detune": String(opened), + "fig-opened-expected": steps(openedRun.expectedSteps), + "fig-opened-tol-factor": factor(openedRun.tolFactor), + "fig-opened-tol-factor-nominal": factor(openedNominal.tolFactor), + "fig-window-undershoot": windowAtDefault.toFixed(2), + "fig-window-unity": windowAtUnity.toFixed(2), + }; +} + +// A blank figure is recoverable by reading the code; a wrong one is not, and a +// dead simulator is worse than either. So the arithmetic is guarded as a whole, +// every write is guarded individually, and nothing here can escape into +// mountSimulator(). +function fillDerivedFigures() { + let figures; + try { + figures = derivedFigures(); + } catch (err) { + console.error("derived prose figures failed: " + err); + return; + } + for (const id of Object.keys(figures)) { + const node = document.getElementById(id); + if (!node) { + console.error("derived prose figure has no element: #" + id); + continue; + } + node.textContent = figures[id]; + } +} + function buildControls(host) { const grid = el("div", { class: "ctl" }); @@ -1079,11 +1232,13 @@

Two things the trace will not tell you

setFault(faultSelect.value || null); }); - grid.appendChild(el("label", { for: "fault", text: "Injected fault" })); + grid.appendChild(el("label", { for: "fault", + text: "Inject a fault (optional)" })); grid.appendChild(faultSelect); grid.appendChild(el("span", { class: "val", text: "" })); grid.appendChild(el("div", { class: "cite note", - text: "Fires on move 2. Section 5 covers what each one means." })); + text: "Fires on move 2. Each one is explained in section 5 — leave it on " + + "\"none\" for now if you have not read that yet." })); host.appendChild(grid); } @@ -1450,6 +1605,9 @@

Two things the trace will not tell you

if (failures.length) { showFailureBanner(failures); } +// Independent of each other on purpose: a prose figure that cannot be computed +// must not cost the reader the simulator. +fillDerivedFigures(); mountSimulator(); diff --git a/tests/docs/test_auto_tune_explainer.py b/tests/docs/test_auto_tune_explainer.py index 1a35c995..273fb369 100644 --- a/tests/docs/test_auto_tune_explainer.py +++ b/tests/docs/test_auto_tune_explainer.py @@ -31,7 +31,15 @@ def _oracle_rows(): def test_oracle_row_count(): - assert len(_oracle_rows()) == 14 + """Guard the regex, not the oracle's size. + + A range, not a pin: adding a legitimate oracle row is good behaviour and + must not fail here. The lower bound catches the regex silently matching + nothing (renamed const, reformatted block); the upper bound catches it + matching far too much, e.g. a greedy match that swallowed the array + literals in the rest of the script. + """ + assert 14 <= len(_oracle_rows()) < 100 @pytest.mark.parametrize("num_steps,expected", _oracle_rows()) From 92cfd19cdda790f9a94806050a9f1fcddb19d6e9 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 14:13:42 -0700 Subject: [PATCH 08/19] docs(tuning): explain the SCALE PV as the source of Hz per microstep Write section 1. The load-bearing claim is that HZ_PER_STEP and HL_HZ_PER_STEP are not what production uses -- they feed only the derived ESTIMATED_MICROSTEPS_PER_HZ constants, which in turn are imported only by the simulation IOC to seed SCALE. Live tuning reads the measured per-cavity SCALE PV instead. Corrects three citations from the plan against current main: - the estimates are consumed in tuner_service.py's SCALE startup handler (104-111), not at the pvproperty declaration (98) - the HL step inversion spans stepper.py:355-356 - adds frequency_tuning.py:479 and :360 for the signed measurement and the SCALE_CALC.B write, which the plan asserted without citing --- docs/explainers/auto_tune.html | 82 +++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 85e30066..4a51a570 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -139,7 +139,87 @@

How auto-tune works

lists how it fails.

-

1. What tuning moves, and with what

+
+

1. What tuning moves, and with what

+ +

+ Detune is the difference between the cavity's frequency and the frequency + we want. Tuning is the act of driving it to zero. +

+ +

Two actuators, with a clear division of labour:

+ + + + + + + + + +
Stepper tunerPiezo
SpeedSlow — seconds to minutes per moveFast — closes a feedback loop
RangeEnormous; tens of millions of microsteps to cold + landingNarrow, centred at 25 V + PIEZO_CENTER_VOLTAGE, linac_utils.py:142 +
RoleGets the cavity to resonance and holds the coarse + positionRejects microphonics once you are there
+ +

Where Hz-per-step comes from

+ +

+ This is the detail most likely to mislead someone reading the source. + linac_utils.py contains HZ_PER_STEP = 1.4 and + HL_HZ_PER_STEP = 18.3 + linac_utils.py:147-148, and it is natural to + assume the tuning code uses them. It does not. +

+ +

+ Those two are estimates. Nothing in utils/sc_linac/ + or applications/ reads either one. Their only consumers are + the two derived constants declared immediately below them + ESTIMATED_MICROSTEPS_PER_HZ, linac_utils.py:151-152, + and the only thing that imports those is the simulation IOC + utils/simulation/tuner_service.py:104-111. It + seeds each simulated cavity's SCALE PV at startup, picking + the 1.3 GHz or 3.9 GHz estimate according to the cryomodule and + then jittering it uniformly by ±20 %. +

+ +

+ Live tuning ignores all of that and reads a measured, per-cavity number + from the SCALE PV: +

+ +
+
Cavity.microsteps_per_hz = 1 / StepperTuner.hz_per_microstep   # reads SCALE
+

cavity.py:339, stepper.py:96

+
+ +

Two consequences worth carrying into the rest of this page:

+ +
    +
  • + The conversion factor is a measured property of one cavity. Two + cavities in the same cryomodule can legitimately disagree, and a stale + or wrong SCALE is a live failure mode — it is the + calibration error slider in section 2. +
  • +
  • + hz_per_microstep returns abs() of the PV + stepper.py:96. RF commissioning measures a + signed Hz/microstep + frequency_tuning.py:479 and writes it to + SCALE_CALC.B + _apply_hz_per_step, frequency_tuning.py:360, + but the loop only ever sees the magnitude. Direction of travel comes + from the sign of the detune, plus the harmonic-linearizer inversion + applied inside issue_move_command() + stepper.py:355-356. So the probe's sign is + information for the operator and the commissioning record — not an + input to the loop. +
  • +
+

2. The loop

From dff58917190936b9c81665d69c4f0097a8eeaab5 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 14:18:52 -0700 Subject: [PATCH 09/19] docs(tuning): document chirp vs SELA and the mid-tune chirp widening Write section 3. Uses Variant A: find_chirp_range still guards on the raw signed value on main, so the negative in-loop entry point bypasses the 400 kHz cap. PR #286 is the fix in flight; when it merges this subsection should be replaced with Variant B from the plan. Verified before writing: - use_sela=True has exactly one caller (setup_cavity.py:219), so the 'exactly one caller' claim stands - setup_tuning passes a positive default 50000; check_detune passes chirp_freq_start * 1.1, which is negative Corrects the plan's caveat: the abs() normalization lives in set_chirp_range (cavity.py:669-677), not in find_chirp_range. The net behaviour the plan describes is right, but the attribution was not. Also cites cavity.py:1155 for the guard itself. --- docs/explainers/auto_tune.html | 116 ++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 4a51a570..1673e114 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -349,7 +349,121 @@

Two things the trace will not tell you

cold landing. Trust the loop structure here; do not trust the smoothness.
-

3. Where the detune number comes from

+
+

3. Where the detune number comes from

+ +

+ _auto_tune does not read the machine itself — it calls a + delta_hz_func handed to it, and is indifferent to where the + number came from. There are two sources. +

+ + + + + + + + + + + + + + + +
Chirp modeSELA mode
Detune PVCHIRP:DFDFBEST
Piezo feedbackDisabled, DC setpoint 0 VEnabled
Drive levelSAFE_PULSED_DRIVE_LEVEL = 10Unchanged
SettlingRF on, 5 s wait, then find a valid chirp + rangeRF on
Used byTuning GUI, RF commissioningAuto setup only
+

setup_tuning(), cavity.py:1117-1148

+ +

+ SELA tuning has exactly one caller. + move_to_resonance(use_sela=True) is invoked from + applications/auto_setup/backend/setup_cavity.py:219 and + nowhere else. The tuning GUI and the RF commissioning phase both tune in + chirp mode. SELA appears on this page because it explains the + piezo-centring pass below — not because you will meet it in + commissioning. +

+ +

The second pass (SELA only)

+ +

+ After converging on detune, move_to_resonance runs + _auto_tune a second time — against + delta_piezo rather than detune, with tolerance + 5 × hz_per_v + cavity.py:739-745. +

+ +

+ The purpose: the piezo has drifted away from its 25 V centre absorbing + slow frequency changes, so it no longer has symmetric authority to reject + microphonics. The second pass uses the stepper to take over that DC + offset, handing the piezo back its full ± range. Note the + harmonic-linearizer sign flip — delta_piezo negates its + result for HLs cavity.py:711-715. +

+ +

Tolerances

+ +

+ 50 Hz, or 500 Hz for harmonic linearizers + cavity.py:735. The HL figure is looser in + proportion to their coarser per-step response. +

+ +

Yes, the chirp range gets re-adjusted mid-tune

+ +

+ Worth knowing, because it means the chirp range at the end of a tune is + not necessarily the one setup_tuning established. + _auto_tune calls check_detune() after + every stepper move. If the detune has gone invalid and the cavity + is in chirp mode, that widens the sweep by 1.1× and retries + cavity.py:904, 908-914. In SELA there is no + range to widen, so it fails hard instead. +

+ +

+ There are two entry points into find_chirp_range, and they do + not behave the same: +

+ + + + + + + + + + + + + + + + +
Entry pointArgument±400 kHz cap enforced?
setup_tuning()positive (50000)Yes
_auto_tune()check_detune()negative (chirp_freq_start * 1.1)No
+ +
+ Known defect, documented as it behaves. + set_chirp_range normalizes with abs() and then + writes chirp_freq_start = -offset + cavity.py:669-677, so the in-loop call passes a + negative number back in. The widening itself still lands on a correct + ± range, because that abs() runs on every write — but + find_chirp_range's recursion guard tests the raw signed + value: if chirp_range < 400000 + cavity.py:1155, which a negative number always + satisfies. On that path the cap never trips, and the range keeps growing + 1.1× per level until the detune goes valid or Python raises + RecursionError. Tracked as a separate fix; this page + describes today's behaviour, because someone chasing a runaway chirp range + is exactly who reads this section. +
+

4. Tune states

5. How it fails

From b7c3b4dbc39879b973e159b30fbda6727765bbfd Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 14:21:05 -0700 Subject: [PATCH 10/19] docs(tuning): add the tune_config state diagram Write section 4. All five citations verified exact against main: linac_utils.py:154-157 (the four TUNE_CONFIG values), cavity.py:747 (RESONANCE on move_to_resonance success), cavity.py:846 (OTHER on _auto_tune entry), cavity.py:164 (DF_COLD), stepper.py:55 (NSTEPS_COLD). The diagram's colours are the literal :root token values rather than var() references, since SVG presentation attributes resolve custom properties less reliably than CSS and this page is opened offline on control-room browsers. Added a comment so a future theme change knows to mirror them here. Not verified: how the diagram actually looks. There is no dev server for this static page, so this was checked by parsing only -- markup is well-formed, the marker id resolves, and the geometry fits the viewBox without overlap. Someone should still open it. --- docs/explainers/auto_tune.html | 105 ++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 1673e114..73f703dc 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -464,7 +464,110 @@

Yes, the chirp range gets re-adjusted mid-tune

is exactly who reads this section. -

4. Tune states

+
+

4. Tune states

+ +

+ Every cavity carries a TUNE_CONFIG PV asserting what its + frequency currently means + linac_utils.py:154-157. +

+ + + + + + + + + + + RESONANCE + 0 + + + OTHER + 3 + + + COLD + 1 + + + PARKED + 2 + + + _auto_tune entry + + + move_to_resonance exit + + + + + + + + + + + + + + + + + + + + + + + +
StateWhat it assertsWritten by
RESONANCE (0)On resonance, ready for beammove_to_resonance() on success + cavity.py:747
COLD (1)At the cold landing frequencyCold-landing tooling
PARKED (2)Stepper parked at a defined referenceParking tooling
OTHER (3)Mid-transition or unknown — do not trust the frequency_auto_tune() on entry + cavity.py:846
+ +
+ A cavity that dies mid-tune is left in OTHER, and + that is correct. _auto_tune writes + OTHER as its first act, but only + move_to_resonance writes RESONANCE on the way + out. Any failure in between — runaway, over temp, abort, invalid detune — + leaves the state at OTHER, which is an honest report that + nobody knows where the cavity is. +
+ +

Two cold-landing numbers, easily conflated

+ + + + + + + + +
DF_COLDThe reference detune, in Hz, at cold landing + cavity.py:164
NSTEPS_COLDThe stepper position, in microsteps, that reached it + stepper.py:55
+

5. How it fails

From 736655f9f5efb30c210dfc9552a734b980e25e3d Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 14:40:08 -0700 Subject: [PATCH 13/19] docs(tuning): link the auto-tune explainer from the docs index Task 10. Audit results, all against real Python: - stepper_tol_factor(10e6) computes to exactly 1.090000, matching TOL_ORACLE's [10000000, 1.09]. The 10e6 -> 1.05 breakpoint in step_tol_des is dead -- `ranges` jumps (5e6, 50e6) and never uses it -- and the page's QUIRK note already says so. - Ported STEP_TOL_DES and TOL_RANGES match linac_utils.py:243-250 and :251 exactly. - Tolerances 50/500 Hz, STEPPER_TEMP_LIMIT 70 C, SAFE_PULSED_DRIVE_LEVEL 10, PIEZO_CENTER_VOLTAGE 25: all confirmed. - No text implies HZ_PER_STEP is used in production; the one remaining mention is explicitly labelled 'nominal' for a worked example, and its linac_utils.py:145-147 span correctly covers MICROSTEPS_PER_STEP through HZ_PER_STEP. Offline check clean (no http, img, src, @import, integrity, fonts), no innerHTML, 17/17 drift tests pass. Step 5, the browser pass, is NOT done -- no browser or JS runtime is available in this environment. Outstanding for a human: console reads SELF-CHECK PASSED with no failure banner, sliders update their readouts, the six inject buttons scroll and fire, and the page reads without horizontal scroll at 1280 px. --- docs/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.md b/docs/index.md index d85582c9..fa4cb836 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ Everything in `applications/` and `displays/` is built on top of `utils/`. Start | [Microphonics](applications/microphonics.md) | Mechanical vibration noise acquisition and analysis | | [Quench Processing](applications/quench_processing.md) | Automated fake-quench reset and real-quench detection | | [Tuning](applications/tuning.md) | Cavity frequency control, state polling, and trend persistence | +| [How auto-tune works](explainers/auto_tune.html) | Interactive explainer: the `_auto_tune` convergence loop, its guards, and how it fails | ### Displays From ab7f59b9efcb111fa4f2e8e2f9308e9e55ed6a21 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 15:20:30 -0700 Subject: [PATCH 14/19] docs(tuning): note that the explainer must be opened locally GitHub never renders HTML from a repo -- the blob view shows source and the raw URL is served text/plain with nosniff. This repo has no GitHub Pages and no site generator, so the index link resolves to 2102 lines of markup for anyone reading docs/ on github.com, which is where it is most likely to be read. Says so in the table rather than leaving the reader to click and wonder. Standing up Pages for docs/ would fix it properly, but that is infrastructure beyond this PR. --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index fa4cb836..1908b731 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,7 @@ Everything in `applications/` and `displays/` is built on top of `utils/`. Start | [Microphonics](applications/microphonics.md) | Mechanical vibration noise acquisition and analysis | | [Quench Processing](applications/quench_processing.md) | Automated fake-quench reset and real-quench detection | | [Tuning](applications/tuning.md) | Cavity frequency control, state polling, and trend persistence | -| [How auto-tune works](explainers/auto_tune.html) | Interactive explainer: the `_auto_tune` convergence loop, its guards, and how it fails | +| [How auto-tune works](explainers/auto_tune.html) | Interactive explainer: the `_auto_tune` convergence loop, its guards, and how it fails. **Open from a local checkout** — GitHub serves HTML as source, so the link above only renders on your own machine. | ### Displays From d8b8860557f84592f3328738fc52ae7932e3fd2c Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 15:36:50 -0700 Subject: [PATCH 15/19] docs(tuning): move the abort Inject button to the row it actually demonstrates Found by running the page. The button sat on the 'Operator abort, stepper / StepperAbortError' row, but the simulator's 'abort' fault models the cavity path -- check_abort() calls turn_off() and raises CavityAbortError (cavity.py:1054-1061). Clicking Inject on the stepper row therefore produced 'CavityAbortError -- aborted, and RF is now off', demonstrating the row below it. Moved the button to the cavity row, which is what it injects. The stepper row keeps no button: the simulator has no separate StepperAbortError fault. --- docs/explainers/auto_tune.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index d061de1d..293c1773 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -729,7 +729,7 @@

6. How it fails

10 s from the request — a 5 s settle sleep before polling starts, plus the 5 s interval stepper.py:129-147, 388-392. - + Operator abort, cavity @@ -739,7 +739,7 @@

6. How it fails

turn_off() before it raises cavity.py:1054-1061. A caller that stops the stepper without setting this leaves the cavity powered. - + From 577c87c9f2c667a9ed61fe5f5545cf60c6f7c348 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 15:49:05 -0700 Subject: [PATCH 16/19] docs(tuning): resync the explainer with main after #270, #286 and #287 main moved four commits and invalidated three passages plus half the citations. #286 (ed8c13e) fixed the chirp-range cap. find_chirp_range now normalizes with abs(int(...)) at cavity.py:1196, so the negative in-loop entry point no longer bypasses the 400 kHz cap. Section 3 shipped Variant A, which documented that bypass as live behaviour -- replaced with Variant B. #287 (d5b1b2d) fixed the zero-step hang. The loop-oracle case asserted 'in the real Python this spins indefinitely'; cavity.py:884-900 now raises DetuneError naming the suspect SCALE. Ported the guard into runAutoTune at the same position (right after est_steps, before the move), added a zero-step outcome and verdict, and changed the oracle case from no-converge to zero-step/0 moves. Also retitled the no-converge label, which pointed at the truncation case that no longer produces it. #270 (ca62991) landed the tuning UI. Section 5's caveat said the re-run gates and the controller were 'not on main' and deferred them; both exist now, so it names them and says the loop page is scoped away from them deliberately rather than pending. Citations: 18 of 37 cite-span references were stale, plus two more in JS comments that a cite-span-only audit misses. #287 added ~45 lines inside _auto_tune and #270 reshaped frequency_tuning.py heavily. Re-resolved every one by anchor text; a sweep of all 50 file:line references in the document now reports zero landing on blank or closing-paren lines. --- docs/explainers/auto_tune.html | 158 ++++++++++++++++----------------- 1 file changed, 78 insertions(+), 80 deletions(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 293c1773..f9ea312d 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -208,9 +208,9 @@

Where Hz-per-step comes from

hz_per_microstep returns abs() of the PV stepper.py:96. RF commissioning measures a signed Hz/microstep - frequency_tuning.py:479 and writes it to + frequency_tuning.py:743 and writes it to SCALE_CALC.B - _apply_hz_per_step, frequency_tuning.py:360, + _apply_hz_per_step, frequency_tuning.py:624, but the loop only ever sees the magnitude. Direction of travel comes from the sign of the detune, plus the harmonic-linearizer inversion applied inside issue_move_command() @@ -248,7 +248,7 @@

2. The loop

if steps_moved > expected_steps * tol_factor: raise DetuneError check_detune() # may widen the chirp range delta_hz = delta_hz_func() # read the machine again -

cavity.py:825-906

+

cavity.py:825-948

Drive it

@@ -374,7 +374,7 @@

3. Where the detune number comes from

Auto setup only -

setup_tuning(), cavity.py:1117-1148

+

setup_tuning(), cavity.py:1159-1190

SELA tuning has exactly one caller. @@ -421,48 +421,23 @@

Yes, the chirp range gets re-adjusted mid-tune

_auto_tune calls check_detune() after every stepper move. If the detune has gone invalid and the cavity is in chirp mode, that widens the sweep by 1.1× and retries - cavity.py:904, 908-914. In SELA there is no + cavity.py:943, 950-956. In SELA there is no range to widen, so it fails hard instead.

- There are two entry points into find_chirp_range, and they do - not behave the same: + Both entry points are safe. find_chirp_range normalizes its + argument with abs(int(...)) before doing anything else + cavity.py:1196, so the negative value that + check_detune() passes in — chirp_freq_start is + negative by construction + set_chirp_range, cavity.py:669-677 — is folded + to a magnitude first. The recursion therefore widens and caps on the same + number, stopping at ±400 kHz whether it was entered from + setup_tuning() or from inside the tuning loop, and raising + DetuneError if no valid detune turned up by then + cavity.py:1192-1223.

- - - - - - - - - - - - - - - - -
Entry pointArgument±400 kHz cap enforced?
setup_tuning()positive (50000)Yes
_auto_tune()check_detune()negative (chirp_freq_start * 1.1)No
- -
- Known defect, documented as it behaves. - set_chirp_range normalizes with abs() and then - writes chirp_freq_start = -offset - cavity.py:669-677, so the in-loop call passes a - negative number back in. The widening itself still lands on a correct - ± range, because that abs() runs on every write — but - find_chirp_range's recursion guard tests the raw signed - value: if chirp_range < 400000 - cavity.py:1155, which a negative number always - satisfies. On that path the cap never trips, and the range keeps growing - 1.1× per level until the detune goes valid or Python raises - RecursionError. Tracked as a separate fix; this page - describes today's behaviour, because someone chasing a runaway chirp range - is exactly who reads this section. -

4. Tune states

@@ -541,7 +516,7 @@

4. Tune states

OTHER (3) Mid-transition or unknown — do not trust the frequency _auto_tune() on entry - cavity.py:846 + cavity.py:851 @@ -573,7 +548,7 @@

5. The commissioning stages

RF commissioning wraps the same loop in a gated, operator-supervised - sequence phases/frequency_tuning.py:110-119. + sequence phases/frequency_tuning.py:123-132. Seven steps:

@@ -612,7 +587,7 @@

What this path does that move_to_resonance does not

Measures Hz/microstep instead of inheriting it. A 50,000-microstep probe move must produce at least 100 Hz of detune change min_probe_delta_hz, - frequency_tuning.py:59, enforced :462. Below that it fails and + frequency_tuning.py:72, enforced :726. Below that it fails and points at the two physical causes: the stepper is not mechanically connected, or the cavity is not at 2 K. @@ -620,7 +595,7 @@

What this path does that move_to_resonance does not

Applies an explicit sign convention. SCALE = -Δ(CHIRP:DF) / Δ(microstep): a positive number of microsteps decreases CHIRP:DF - frequency_tuning.py:472. + frequency_tuning.py:736.
  • Waits for the operator before writing. And it writes @@ -628,12 +603,12 @@

    What this path does that move_to_resonance does not

    is a read-only calc output the IOC recomputes from it (SCALE = SCALE_CALC.B / 256), so writing SCALE directly is silently reverted - stepper.py:105-116, frequency_tuning.py:363-365. + stepper.py:105-116, frequency_tuning.py:627-629.
  • Refuses to tune until DF_COLD is pushed and matches the recorded cold-landing frequency within 1 Hz - frequency_tuning.py:154, tolerance at :152. + frequency_tuning.py:170, tolerance at :168. The reason it compares against the record rather than checking validity: DF_COLD defaults to a perfectly valid 0, so there is no INVALID severity to key off. @@ -641,9 +616,9 @@

    What this path does that move_to_resonance does not

  • Guards the stepper temperature at STEPPER_TEMP_LIMIT = 70 °C - frequency_tuning.py:53, raisable for a re-run + frequency_tuning.py:66, raisable for a re-run by an explicit operator acknowledgement - over_temp_ack_c, :548. The raised ceiling is + over_temp_ack_c, :807. The raised ceiling is passed straight into _auto_tune's max_stepper_temp, which still fails hard on a breach — the acknowledgement moves the line, it does not add a retry. @@ -652,9 +627,13 @@

    What this path does that move_to_resonance does not

    Not covered here: the operator controls. This section - describes the backend phase logic. The screen that drives it — the - per-stage re-run gates, and what the Abort button commands — is documented - separately once that work merges. Section 6 covers the abort + describes the backend phase logic only. The screen that drives it now + exists — a 1,700-line controller + ui/controllers/frequency_tuning_controller.py + plus three re-run gates that re-establish cavity state before stages 2, 3 + and 4 _check_state_for_stage_2/3/4, + frequency_tuning.py:324, 368, 404 — and it is deliberately out of + scope for a page about the convergence loop. Section 6 covers the abort mechanism at the stepper and cavity level, which is what _auto_tune itself sees.
    @@ -686,7 +665,7 @@

    6. How it fails

    DetuneError Cavity off, or the chirp range is wrong before the loop even starts. Checked once, before the first move - cavity.py:833. + cavity.py:834. @@ -700,7 +679,7 @@

    6. How it fails

    Detune invalid mid-loop, SELA DetuneError No range to widen, so it fails hard - cavity.py:915-926. Auto setup only. + cavity.py:957-968. Auto setup only. @@ -708,7 +687,7 @@

    6. How it fails

    StepperTempError There is no cool-down and no retry. The loop raises and stops; a human has to let the motor cool and re-run - tuning cavity.py:851-862. + tuning cavity.py:856-867. @@ -737,7 +716,7 @@

    6. How it fails

    Setting cavity.abort_flag is the path that also turns the RF offcheck_abort() calls turn_off() before it raises - cavity.py:1054-1061. A caller that stops the + cavity.py:1096-1103. A caller that stops the stepper without setting this leaves the cavity powered. @@ -783,7 +762,7 @@

    6. How it fails

    [-50000, 3.888889], ]; -// Traced from Cavity._auto_tune (cavity.py:825-906) against a Python +// Traced from Cavity._auto_tune (cavity.py:825-948) against a Python // reference that reuses the real stepper_tol_factor. Every count below was // produced by that trace, not by hand arithmetic — see the note on case 1 for // why hand arithmetic gets this wrong. @@ -980,32 +959,30 @@

    6. How it fails

    expect: { outcome: "runaway", moves: 3 }, }, { - name: "est_steps truncating to zero never terminates", + name: "est_steps truncating to zero raises rather than spinning", input: { // A believed scale of 100 Hz/microstep is wildly wrong (nominal is // 0.0055), but it is the cleanest way to reach this state. detune0: 60, scaleHzPerMicrostep: 100, trueHzPerMicrostep: 100, tolerance: 50, undershoot: 0.9, }, - // THIS CASE DOCUMENTS A REAL BUG IN THE PYTHON LOOP, not a quirk of the - // simulator. microsteps_per_hz = 0.01, so - // est_steps = int(0.9 * 60 * 0.01) = int(0.54) = 0. + // microsteps_per_hz = 0.01, so est_steps = int(0.9 * 60 * 0.01) + // = int(0.54) = 0. + // + // A zero-step move changes nothing: delta_hz would be re-read as the same + // 60 Hz, still > 50, and because steps_moved += abs(0) never grows the + // runaway guard could never fire either -- expected_steps is 0 here too, + // so the budget is 0 and `0 > 0` is false forever. Nothing in the loop + // body would bound the iteration count. // - // A zero-step move changes nothing: delta_hz is re-read as the same - // 60 Hz, which is still > 50, so the loop goes around again. And because - // steps_moved += abs(0) never grows, the runaway guard can never fire -- - // expected_steps is 0 here too, so the budget is 0 and `0 > 0` is false - // forever. Nothing bounds the iteration count. In the real Python this - // spins indefinitely, doing nothing, until something outside it aborts. + // That was a real hang until the guard at cavity.py:884-900 landed. It now + // raises DetuneError naming the suspect SCALE instead of spinning. // // The precondition is a believed scale above 0.9 * tolerance // (45 Hz/microstep at the default 50 Hz tolerance), i.e. a SCALE PV about - // four orders of magnitude too large. So it needs a badly wrong - // calibration to reach -- but nothing in the loop stops it. - // - // MAX_ITERATIONS is what turns it into a reported outcome here. Filed - // separately; deliberately not fixed in the Python from this page. - expect: { outcome: "no-converge" }, + // four orders of magnitude too large -- so it needs a badly wrong + // calibration to reach at all. + expect: { outcome: "zero-step", moves: 0 }, }, ]; @@ -1102,7 +1079,7 @@

    6. How it fails

    // during a single move, not just once per iteration as modelled here. // // Also not modelled: the pre-loop `if self.detune_invalid` guard -// (cavity.py:833), which raises DetuneError before the first move ever +// (cavity.py:834), which raises DetuneError before the first move ever // happens. Deliberate — this port starts from a valid detune reading, and the // detune_invalid_* faults below cover the in-loop check_detune() path instead. // @@ -1170,8 +1147,8 @@

    6. How it fails

    // the page does not teach the wrong precedence. // // There are TWO abort paths and they behave differently. This models the - // loop-top one: Cavity.check_abort (cavity.py:1054), reached at - // cavity.py:849 between moves, which calls turn_off() and raises + // loop-top one: Cavity.check_abort (cavity.py:1096), reached at + // cavity.py:854 between moves, which calls turn_off() and raises // CavityAbortError. No move is in flight, so it stops promptly — and it // TURNS RF OFF. // @@ -1214,6 +1191,24 @@

    6. How it fails

    const estSteps = Math.trunc(undershoot * deltaHz * microstepsPerHz); + // A zero step estimate commands no motion, so the detune cannot change and + // steps_moved cannot grow -- the runaway guard can never fire. The loop + // would otherwise spin forever doing nothing. Bails out instead + // (cavity.py:884-900). Placed here, right after est_steps and before the + // move, exactly where the real guard sits. + if (estSteps === 0) { + outcome = "zero-step"; + const hzPerMicrostep = 1 / microstepsPerHz; + error = `DetuneError: step estimate rounded to zero with detune ` + + `${deltaHz} Hz outside tolerance ${tolerance} Hz; ` + + `check SCALE (hz_per_microstep=${hzPerMicrostep})`; + log.push("The believed SCALE is so large that 0.9 x detune x " + + "microsteps_per_hz truncates to zero microsteps. No motion is " + + "commanded, so nothing can change -- the loop raises rather " + + "than spinning."); + break; + } + // int(abs(est_steps) * 1.1), the max_steps argument handed to // StepperTuner.move(). Recorded for display only, and deliberately NOT // applied to the simulated move, because it never limits anything at this @@ -1287,8 +1282,9 @@

    6. How it fails

    // // On "limit" it is stale by a full move — the simulated cavity moved but // the exception propagates out of move() before delta_hz is re-read, so - // the loop never learns the new value. On "abort" and "temp" nothing moved, - // so it is current. On "sela-invalid" it is a number, but the entire + // the loop never learns the new value. On "abort", "temp" and "zero-step" + // nothing moved, so it is current — "zero-step" breaks before the move is + // commanded at all. On "sela-invalid" it is a number, but the entire // meaning of that state is that the machine's detune reading is INVALID, // so the value should be presented as unavailable rather than precise. finalDetuneHz: deltaHz, @@ -1467,7 +1463,7 @@

    6. How it fails

    note: "1.0 means SCALE is exactly right" }, { id: "undershoot", label: "Undershoot factor", unit: "", min: 0.5, max: 1.3, step: 0.05, value: 0.9, - note: "Hard-coded 0.9 in the real loop (cavity.py:868)" }, + note: "Hard-coded 0.9 in the real loop (cavity.py:874)" }, ]; // Option text for the fault picker. Keyed off FAULT_ORACLE so a fault added to @@ -1494,8 +1490,10 @@

    6. How it fails

    color: "--bad" }, "sela-invalid": { label: "DetuneError — invalid detune in SELA", color: "--bad" }, - "no-converge": { label: "Never terminated — see the truncation case", + "no-converge": { label: "Never terminated — hit the iteration ceiling", color: "--bad" }, + "zero-step": { label: "DetuneError — step estimate rounded to zero", + color: "--bad" }, abort: { label: "CavityAbortError — aborted, and RF is now off", color: "--warn" }, }; From 996bad621933a752d1247bf2fac430c00422e590 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Fri, 21 Aug 2026 16:02:36 -0700 Subject: [PATCH 17/19] docs(tuning): widen the detune slider and make the zero-step guard drivable Three additions on top of the resync in 577c87c. Widened the starting-detune slider to -50 kHz..200 kHz. A cavity coming off cooldown can sit hundreds of kHz out, so +/-50 kHz understated the envelope tuning actually walks in from. This sharpens section 2 rather than diluting it: the tolerance factor tightens from 1.369x at 5 kHz to 1.0369x at 200 kHz, so the calibration-error window visibly narrows as the detune grows. 400 kHz is the most that would ever be worth offering, since that is where find_chirp_range's cap stops widening -- past it the detune cannot be measured at all. Added a bad_scale injection so #287's guard can be watched rather than only read about. The sliders cannot reach the zero-step state -- it needs a detune below 0.11 Hz while also above the 5 Hz tolerance floor -- and widening the scale slider far enough would put values no cavity has in front of the reader. The injection rewrites SCALE mid-tune instead, which is also the honest route in: microsteps_per_hz is re-read every iteration, so a bad _apply_hz_per_step probe takes effect on the next move. Needed a mutable effective scale, kept separate from microstepsPerHz because expected_steps and the step budget are computed once before the first move, so a mid-tune SCALE change moves the estimate without moving the budget. Also gave it a failure-table row, and noted on the step-budget row that the runaway message now reports an unchanged detune -- the stuck-tuner signal #287 added. selfCheck() now asserts every Inject button sits on a row whose Raises column names the exception the simulator reports. That is the check that would have caught the abort button being on the StepperAbortError row while injecting the cavity path; verified it fails if that arrangement is restored. SELF-CHECK PASSED (14 tol-factor, 12 loop, 7 fault cases). Generated with AI Co-Authored-By: SLAC AI --- docs/explainers/auto_tune.html | 80 ++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index f9ea312d..03d6c569 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -657,9 +657,26 @@

    6. How it fails

    SCALE is miscalibrated, or the tuner is slipping mechanically. The loop asked for more steps than stepper_tol_factor allows for the detune it started - with. + with. If the reported detune never changed across the whole run, the + message says so — that distinguishes a tuner that is mechanically + stuck while still reporting motion from honest over-travel. + + Step estimate rounds to zero + DetuneError + SCALE is implausibly large, so + int(0.9 × delta_hz × microsteps_per_hz) + truncates to 0 while the detune is still outside tolerance. A zero + step commands no motion, so nothing would ever change. The loop + raises immediately and names SCALE and the offending + hz_per_microstep + cavity.py:884-900. The injection rewrites + SCALE mid-tune, which is the real route in: the loop + re-reads it every iteration, so a bad value written by + _apply_hz_per_step takes effect on the next move. + + Detune invalid at entry DetuneError @@ -1011,6 +1028,10 @@

    6. How it fails

    // Checked at the END of the loop body, so move 2 is completed and recorded // before this breaks -- hence 2 moves, not 1. { fault: "detune_invalid_sela", outcome: "sela-invalid", moves: 2 }, + // Move 1 lands normally at 500 Hz. The rewritten SCALE then truncates move + // 2's estimate to zero, and the guard breaks before the move is recorded -- + // hence 1 move. Without the guard this is where the loop used to hang. + { fault: "bad_scale", outcome: "zero-step", moves: 1 }, ]; // Shared input for FAULT_ORACLE, kept separate so the faults vary one thing. @@ -1129,6 +1150,11 @@

    6. How it fails

    let faultIteration = null; // Effective response, degraded by the mechanical-slip fault. let responseHz = trueHzPerMicrostep; + // Effective believed scale. Separate from microstepsPerHz because the real + // loop re-reads SCALE every iteration while expected_steps and the step + // budget above are computed ONCE, before the first move -- so a mid-tune + // SCALE change moves the estimate without moving the budget. + let effMicrostepsPerHz = microstepsPerHz; while (Math.abs(deltaHz) > tolerance) { if (iterations.length >= MAX_ITERATIONS) { @@ -1178,6 +1204,22 @@

    6. How it fails

    break; } + // The real loop re-reads microsteps_per_hz from the SCALE PV on every + // iteration (cavity.py:873), so a SCALE write that lands mid-tune takes + // effect on the next move. That is the plausible route into the zero-step + // state: RF commissioning writes SCALE_CALC.B from a measured probe + // (_apply_hz_per_step), so a bad probe or a bad manual entry is picked up + // here rather than at setup. 1e6 Hz/microstep is the value #287's + // regression test uses. + if (firing && fault === "bad_scale") { + effMicrostepsPerHz = 1 / 1e6; + if (i === faultAtIteration) { + log.push("SCALE was rewritten mid-tune to an implausible " + + "1e6 Hz/microstep. The loop re-reads it every iteration, so " + + "the next step estimate truncates to zero."); + } + } + if (firing && fault === "slip") { responseHz = trueHzPerMicrostep * 0.1; // Annotated once, on the move where it starts. The degraded response @@ -1189,7 +1231,7 @@

    6. How it fails

    } } - const estSteps = Math.trunc(undershoot * deltaHz * microstepsPerHz); + const estSteps = Math.trunc(undershoot * deltaHz * effMicrostepsPerHz); // A zero step estimate commands no motion, so the detune cannot change and // steps_moved cannot grow -- the runaway guard can never fire. The loop @@ -1198,7 +1240,7 @@

    6. How it fails

    // move, exactly where the real guard sits. if (estSteps === 0) { outcome = "zero-step"; - const hzPerMicrostep = 1 / microstepsPerHz; + const hzPerMicrostep = 1 / effMicrostepsPerHz; error = `DetuneError: step estimate rounded to zero with detune ` + `${deltaHz} Hz outside tolerance ${tolerance} Hz; ` + `check SCALE (hz_per_microstep=${hzPerMicrostep})`; @@ -1395,6 +1437,27 @@

    6. How it fails

    failures.push(`fault ${c.fault}: duplicate log lines`); } } + + // Every Inject button must sit on a row whose "Raises" column names the same + // exception the simulator actually reports. This was a real defect: the abort + // button sat on the StepperAbortError row while the fault it injects is the + // cavity path, which raises CavityAbortError and turns RF off -- the exact + // distinction section 6 exists to draw. A reader clicking that row was shown + // the other path's outcome. Cheap to check, so it is checked. + for (const tr of document.querySelectorAll("#failures tbody tr")) { + const btn = tr.querySelector("button[data-fault]"); + if (!btn) continue; + const raises = tr.children[1].textContent.trim(); + // "— recovers" rows raise nothing, so there is no class to match. + if (raises.startsWith("\u2014")) continue; + const got = runAutoTune({ ...FAULT_BASE, fault: btn.dataset.fault }); + const label = (OUTCOMES[got.outcome] || {}).label || ""; + if (!label.includes(raises)) { + failures.push( + `fault ${btn.dataset.fault}: row says ${raises}, verdict says ${label}` + ); + } + } if (failures.length) { console.error("SELF-CHECK FAILED\n" + failures.join("\n")); } else { @@ -1447,8 +1510,16 @@

    6. How it fails

    } const SLIDERS = [ + // Range is deliberately wide and asymmetric. A cavity coming off cooldown + // can sit hundreds of kHz high, so a +/-50 kHz slider understated the real + // envelope; the upper bound is what tuning actually has to walk in from. + // 400 kHz is the most that would ever be worth offering, because that is + // where find_chirp_range's cap stops widening (see section 3) -- past it the + // detune cannot be measured, so the loop never sees it. { id: "detune0", label: "Starting detune", unit: "Hz", - min: -50000, max: 50000, step: 100, value: 5000 }, + min: -50000, max: 200000, step: 100, value: 5000, + note: "Hundreds of kHz is normal off cooldown; beyond ~400 kHz the " + + "chirp cannot measure it at all" }, { id: "tolerance", label: "Tolerance", unit: "Hz", min: 5, max: 1000, step: 5, value: 50, note: "50 Hz standard, 500 Hz for harmonic linearizers (cavity.py:735)" }, @@ -1476,6 +1547,7 @@

    6. How it fails

    slip: "slip — motor turns, cavity barely responds", detune_invalid_chirp: "detune_invalid_chirp — recoverable, widens the chirp", detune_invalid_sela: "detune_invalid_sela — fatal, no chirp to widen", + bad_scale: "bad_scale — SCALE rewritten mid-tune, step estimate hits zero", }; // Outcome -> what a human should read, and which of the three status colours From 945386f11211af1bd095ebf74ce8df86bef9a12d Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Mon, 24 Aug 2026 14:38:30 -0700 Subject: [PATCH 18/19] docs(tuning): correct the piezo's role and NSTEPS_COLD in the explainer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review from Sebastian: - The piezo does not reject microphonics. It runs a slow (~few Hz) frequency feedback that compensates drift. Applied his wording in the role row, and followed it through to the two other places the page implied active microphonics rejection: the speed row's "closes a feedback loop", which now reads as a contradiction next to "slow feedback", and the piezo-centring paragraph in section 3. - NSTEPS_COLD is not a stepper position. It is the signed return-trip step count, resonance back to cold landing (frequency_tuning.py:967-971) — the cold landing frequency is recorded before the stepper moves at all. Also Copilot: read the explainer with an explicit UTF-8 encoding. The page contains Δ, ° and ×, and the repo's only other read_text already pins it. Co-Authored-By: Claude Opus 5 --- docs/explainers/auto_tune.html | 18 +++++++++++------- tests/docs/test_auto_tune_explainer.py | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 03d6c569..4f7dd8ca 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -153,13 +153,15 @@

    1. What tuning moves, and with what

    Stepper tunerPiezo SpeedSlow — seconds to minutes per move - Fast — closes a feedback loop + Fast — a voltage change, not a mechanical move + piezo.py:50 RangeEnormous; tens of millions of microsteps to cold landingNarrow, centred at 25 V PIEZO_CENTER_VOLTAGE, linac_utils.py:142 RoleGets the cavity to resonance and holds the coarse - positionRejects microphonics once you are there + positionSlow (~few Hz) frequency feedback once you are + there @@ -398,9 +400,9 @@

    The second pass (SELA only)

    The purpose: the piezo has drifted away from its 25 V centre absorbing - slow frequency changes, so it no longer has symmetric authority to reject - microphonics. The second pass uses the stepper to take over that DC - offset, handing the piezo back its full ± range. Note the + slow frequency changes, so it no longer has symmetric range left to + follow further drift. The second pass uses the stepper to take over that + DC offset, handing the piezo back its full ± range. Note the harmonic-linearizer sign flip — delta_piezo negates its result for HLs cavity.py:711-715.

    @@ -538,8 +540,10 @@

    Two cold-landing numbers, easily conflated

    The reference detune, in Hz, at cold landing cavity.py:164 NSTEPS_COLD - The stepper position, in microsteps, that reached it - stepper.py:55 + The signed step count for the return trip, resonance + back to cold landing — a distance, not a position + stepper.py:55, + frequency_tuning.py:967-971
  • diff --git a/tests/docs/test_auto_tune_explainer.py b/tests/docs/test_auto_tune_explainer.py index 273fb369..6fec9875 100644 --- a/tests/docs/test_auto_tune_explainer.py +++ b/tests/docs/test_auto_tune_explainer.py @@ -19,7 +19,7 @@ / "explainers" / "auto_tune.html" ) -HTML = EXPLAINER.read_text() +HTML = EXPLAINER.read_text(encoding="utf-8") def _oracle_rows(): From 1ec2bc360d363e91c76253bf2ab8ac071f24cc17 Mon Sep 17 00:00:00 2001 From: Lisa Zacarias Date: Mon, 24 Aug 2026 14:38:50 -0700 Subject: [PATCH 19/19] fix(rf-commissioning): drop the incorrect 2 K hint from the probe-move failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the direction probe moves 50,000 steps and sees less than 100 Hz of detune change, the failure message told the operator to check "that the stepper is mechanically connected and the cavity is at 2 K". The 2 K half cannot be the cause. Reaching that branch means both detune readings succeeded, and detune is only visible when the cavity is at 2 K — stepper.py:349 says exactly that about check_detune. A warm cavity does not produce a small probe delta; it produces no usable detune at all. So the hint sends the operator to look at a cryo plant that is demonstrably fine, past the one cause that fits: the stepper turning without moving the tuner. Raised by Sebastian in review of #288. Co-Authored-By: Claude Opus 5 --- docs/explainers/auto_tune.html | 4 ++-- .../applications/rf_commissioning/phases/frequency_tuning.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/explainers/auto_tune.html b/docs/explainers/auto_tune.html index 4f7dd8ca..66007cab 100644 --- a/docs/explainers/auto_tune.html +++ b/docs/explainers/auto_tune.html @@ -592,8 +592,8 @@

    What this path does that move_to_resonance does not

    50,000-microstep probe move must produce at least 100 Hz of detune change min_probe_delta_hz, frequency_tuning.py:72, enforced :726. Below that it fails and - points at the two physical causes: the stepper is not mechanically - connected, or the cavity is not at 2 K. + points at the physical cause: the stepper is not mechanically connected + to the tuner.
  • Applies an explicit sign convention. diff --git a/src/sc_linac_physics/applications/rf_commissioning/phases/frequency_tuning.py b/src/sc_linac_physics/applications/rf_commissioning/phases/frequency_tuning.py index d6c22988..aa6464da 100644 --- a/src/sc_linac_physics/applications/rf_commissioning/phases/frequency_tuning.py +++ b/src/sc_linac_physics/applications/rf_commissioning/phases/frequency_tuning.py @@ -729,7 +729,7 @@ def _probe_stepper_direction(self) -> PhaseStepResult: message=( f"Probe move of {probe} steps produced only {abs(delta):.1f} Hz change " f"(minimum {self.limits.min_probe_delta_hz:.1f} Hz required). " - "Check that the stepper is mechanically connected and the cavity is at 2 K." + "Check that the stepper is mechanically connected to the tuner." ), )