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 tuner | Piezo | |
|---|---|---|
| Speed | Slow — seconds to minutes per move | +Fast — a voltage change, not a mechanical move + piezo.py:50 |
| Range | Enormous; tens of millions of microsteps to cold + landing | Narrow, centred at 25 V + PIEZO_CENTER_VOLTAGE, linac_utils.py:142 + |
| Role | Gets the cavity to resonance and holds the coarse + position | Slow (~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:
+ +-
+
-
+ The conversion factor is a measured property of one cavity. Two
+ cavities in the same cryomodule can legitimately disagree, and a stale
+ or wrong
SCALEis a live failure mode — it is the + calibration error slider in section 2. +
+ -
+
hz_per_microstepreturnsabs()of the PV + stepper.py:96. RF commissioning measures a + signed Hz/microstep + frequency_tuning.py:743 and writes it to +SCALE_CALC.B+ _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 insideissue_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
+ +
+ 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_steps | +max_steps arg | cumulative | +Δ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. +
+ +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 mode | SELA mode | |
|---|---|---|
| Detune PV | CHIRP:DF |
+ DFBEST |
| Piezo feedback | Disabled, DC setpoint 0 V | +Enabled |
| Drive level | +SAFE_PULSED_DRIVE_LEVEL = 10 |
+ Unchanged |
| Settling | RF on, 5 s wait, then find a valid chirp + range | RF on |
| Used by | Tuning GUI, RF commissioning | +Auto 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.
+
| State | What it asserts | +Written by |
|---|---|---|
RESONANCE (0) |
+ On resonance, ready for beam | +move_to_resonance() on success
+ cavity.py:747 |
COLD (1) |
+ At the cold landing frequency | +Cold-landing tooling |
PARKED (2) |
+ Stepper parked at a defined reference | +Parking tooling |
OTHER (3) |
+ Mid-transition or unknown — do not trust the frequency | +_auto_tune() on entry
+ cavity.py:851 |
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_COLD |
+ The reference detune, in Hz, at cold landing + cavity.py:164 |
NSTEPS_COLD |
+ 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 |
5. The commissioning stages
+ ++ RF commissioning wraps the same loop in a gated, operator-supervised + sequence phases/frequency_tuning.py:123-132. + Seven steps: +
+ +| # | Step | What it does to the machine | +
|---|---|---|
| 1 | verify_initial_state |
+ Confirms the stepper is idle, then prepares the cavity: SSA on,
+ interlocks reset, setup_tuning() into chirp
+ mode |
| 2 | record_cold_landing |
+ Records the cold-landing detune; the operator pushes it to
+ DF_COLD from the UI |
| 3 | probe_stepper_direction |
+ Moves ±50,000 microsteps and measures the detune + response |
| 4 | apply_hz_per_step |
+ Writes the confirmed Hz/full-step to
+ SCALE_CALC.B |
| 5 | tune_to_resonance |
+ Delegates to _auto_tune with a temperature guard,
+ then writes NSTEPS_COLD |
| 6 | measure_pi_modes |
+ Single-cavity FSCAN for the 8π/9 and 7π/9 parasitic + modes |
| 7 | record_results |
+ Writes the phase record to the commissioning database |
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:72, enforced :726. Below that it fails and + points at the physical cause: the stepper is not mechanically connected + to the tuner. + +
-
+ Applies an explicit sign convention.
+
SCALE = -Δ(CHIRP:DF) / Δ(microstep): a positive + number of microsteps decreasesCHIRP:DF+ frequency_tuning.py:736. +
+ -
+ Waits for the operator before writing. And it writes
+
SCALE_CALC.B, notSCALE—SCALE+ is a read-only calc output the IOC recomputes from it + (SCALE = SCALE_CALC.B / 256), so writingSCALE+ directly is silently reverted + stepper.py:105-116, frequency_tuning.py:627-629. +
+ -
+ Refuses to tune until
DF_COLDis pushed + and matches the recorded cold-landing frequency within 1 Hz + frequency_tuning.py:170, tolerance at :168. + The reason it compares against the record rather than checking validity: +DF_COLDdefaults to a perfectly valid 0, so there is no + INVALID severity to key off. +
+ -
+ Guards the stepper temperature at
+
STEPPER_TEMP_LIMIT= 70 °C + frequency_tuning.py:66, raisable for a re-run + by an explicit operator acknowledgement + 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. +
+
_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. +
+ +| Failure | Raises | Cause and what to do | +|
|---|---|---|---|
| Step budget exceeded | +DetuneError | +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. 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 | +Cavity 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 | +— recovers | +check_detune() widens the chirp range 1.1× and
+ carries on. See section 3 on the cap. |
+ + |
| Detune invalid mid-loop, SELA | +DetuneError | +No range to widen, so it fails hard + cavity.py:957-968. Auto setup only. | ++ |
| Stepper over temperature | +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:856-867. | ++ |
| Limit switch hit | +StepperError | +Checked after every completed move — the motor stopped for a bad + reason rather than because it arrived + stepper.py:419-431. | ++ |
| Operator abort, stepper | +StepperAbortError | +Setting 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, cavity | +CavityAbortError | +Setting cavity.abort_flag is the path that also turns
+ the RF off — check_abort() calls
+ turn_off() before it raises
+ cavity.py:1096-1103. A caller that stops the
+ stepper without setting this leaves the cavity powered. |
+ + |