diff --git a/MANIFEST.in b/MANIFEST.in index 691953e8..cfbda047 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..66007cab --- /dev/null +++ b/docs/explainers/auto_tune.html @@ -0,0 +1,2176 @@ + + + + + +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

+ +

+ 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 — 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 + positionSlow (~few Hz) frequency feedback 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:

+ + +
+
+

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-948

+
+ +

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  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. +

+ +

+ 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, +  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 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 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. +

+ +
+ 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

+ +

+ _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:1159-1190

+ +

+ 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 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. +

+ +

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:943, 950-956. In SELA there is no + range to widen, so it fails hard instead. +

+ +

+ 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. +

+
+
+

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:851
+ +
+ 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 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
+
+
+

5. The commissioning stages

+ +

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

+ + + + + + + + + + + + + + + + + + + + +
#StepWhat it does to the machine
1verify_initial_stateConfirms the stepper is idle, then prepares the cavity: SSA on, + interlocks reset, setup_tuning() into chirp + mode
2record_cold_landingRecords the cold-landing detune; the operator pushes it to + DF_COLD from the UI
3probe_stepper_directionMoves ±50,000 microsteps and measures the detune + response
4apply_hz_per_stepWrites the confirmed Hz/full-step to + SCALE_CALC.B
5tune_to_resonanceDelegates to _auto_tune with a temperature guard, + then writes NSTEPS_COLD
6measure_pi_modesSingle-cavity FSCAN for the 8π/9 and 7π/9 parasitic + modes
7record_resultsWrites the phase record to the commissioning database
+ +

What this path does that move_to_resonance does not

+ + + +
+ Not covered here: the operator controls. This section + 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. +
+ +
+
+

6. How it fails

+ +

+ Each row below has a button that injects that fault into the simulator in + section 2 and scrolls you back to it, so you can watch the loop react. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FailureRaisesCause and what to do
Step budget exceededDetuneErrorSCALE 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. 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 zeroDetuneErrorSCALE 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 entryDetuneErrorCavity off, or the chirp range is wrong before the loop even + starts. Checked once, before the first move + cavity.py:834.
Detune invalid mid-loop, chirp— recoverscheck_detune() widens the chirp range 1.1× and + carries on. See section 3 on the cap.
Detune invalid mid-loop, SELADetuneErrorNo range to widen, so it fails hard + cavity.py:957-968. Auto setup only.
Stepper over temperatureStepperTempErrorThere 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:856-867.
Limit switch hitStepperErrorChecked after every completed move — the motor stopped for a bad + reason rather than because it arrived + stepper.py:419-431.
Operator abort, stepperStepperAbortErrorSetting stepper_tuner.abort_flag stops a move + already in progress: the polling loop in + issue_move_command checks it every 5 s while the motor + runs, writes 1 to ABORT_REQ, and raises. Worst case about + 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, cavityCavityAbortErrorSetting cavity.abort_flag is the path that also turns + the RF offcheck_abort() calls + turn_off() before it raises + cavity.py:1096-1103. A caller that stops the + stepper without setting this leaves the cavity powered.
+
+
+ + + diff --git a/docs/index.md b/docs/index.md index d85582c9..1908b731 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. **Open from a local checkout** — GitHub serves HTML as source, so the link above only renders on your own machine. | ### Displays 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." ), ) 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..6fec9875 --- /dev/null +++ b/tests/docs/test_auto_tune_explainer.py @@ -0,0 +1,57 @@ +"""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(encoding="utf-8") + + +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(): + """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()) +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)