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 labor:
| Stepper tuner | Piezo | |
|---|---|---|
| Speed | Slow — a mechanical move. Roughly 50 s at full range: 1,000,000 usteps at 20,000 usteps/s linac_utils.py::DEFAULT_STEPPER_MAX_STEPS, linac_utils.py::DEFAULT_STEPPER_SPEED; STEP:VELO EGU is usteps/ |
Fast — a voltage change, not a mechanical move piezo.py::Piezo.voltage_pv |
| Range | Enormous; tens of millions of microsteps to cold landing linac_utils.py::stepper_tol_factor “50,000,000 steps for cold landing” |
Narrow, centered at 25 V linac_utils.py::PIEZO_CENTER_VOLTAGE |
| Role | Gets the cavity to resonance and holds the coarse position cavity.py::Cavity.move_to_resonance |
Closed-loop frequency feedback, driven off an integrator setpoint MODECTRL/MODESTAT, piezo.py::Piezo.feedback_control_pv, piezo.py::Piezo.feedback_stat_pv; INTEG_SP, piezo.py::Piezo.feedback_setpoint_pv |
Open question: the piezo loop's timescale is not in this repo
The code gives a
closed-loop mode, an integrator setpoint, and a centering pass that runs
under SELA
INTEG_SP, piezo.py::Piezo.feedback_setpoint_pv; cavity.py::Cavity.move_to_resonance “if use_sela:”. None of that fixes a bandwidth, and no figure appears anywhere in src/.
CHECK: what is the cutoff in Hz, and does the loop reject microphonics or only follow slow drift? Open for the same reason: why the piezo ends up off its 25 V center at all. Section 3 shows the correction, not the cause.
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::HZ_PER_STEP, linac_utils.py::HL_HZ_PER_STEP, and it is natural to assume the tuning code uses them. It does not.
The 1.4 is not arbitrary — the prototype tuner measured a slow-tuner
sensitivity of 1.4 Hz/step over a ~450 kHz range
Pischalnikov et al., IPAC2015, WEPTY035. The repo calls both constants "very rough values obtained empirically"
linac_utils.py “These are very rough values obtained empirically”, which is the right warning: they are one prototype's numbers, not this
cavity's.
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
linac_utils.py::ESTIMATED_MICROSTEPS_PER_HZ, linac_utils.py::ESTIMATED_MICROSTEPS_PER_HZ_HL, and the only thing that imports those is the simulation IOC
utils/simulation/tuner_service.py::StepperPVGroup.hz_per_microstep. 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::Cavity.microsteps_per_hz, stepper.py::StepperTuner.hz_per_microstep
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 PVstepper.py::StepperTuner.hz_per_microstep. RF commissioning measures a signed Hz/microstepphases/frequency_tuning.py::FrequencyTuningPhase._probe_stepper_direction “signed_hz_per_microstep = -delta / probe”and writes it toSCALE_CALC.Bphases/frequency_tuning.py::FrequencyTuningPhase._apply_hz_per_step, 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::StepperTuner.issue_move_command “num_steps *= -1”. 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::Cavity._auto_tune
Drive it¶
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
the detune sitting exactly on the tolerance 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 HZ_PER_STEP / MICROSTEPS_PER_STEP scale of
1.4/256 (linac_utils.py::MICROSTEPS_PER_STEP, linac_utils.py::HZ_PER_STEP) 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.753× budget, while the
5000 Hz the page opens on 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 that opening detune, 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 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 modeled cavity responds linearly and
without noise. The real tuner does not: the prototype measured ~30 steps of
backlash and ~45 Hz of hysteresis over a ±150 Hz range
Pischalnikov et al., IPAC2015, WEPTY035, which is why the tolerance factors are far wider than a linear response
needs — 5× the estimated steps below 10,000 steps, 1.01× near cold landing,
fitted empirically against large dead zones
linac_utils.py::stepper_tol_factor. 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 |
cavity.py::Cavity.setup_tuning
SELA tuning has exactly one caller. move_to_resonance(use_sela=True) is
invoked from
applications/auto_setup/backend/setup_cavity.py::SetupCavity.request_ramp “self.move_to_resonance(use_sela=True)”
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-centering
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::Cavity.move_to_resonance “if use_sela:”.
What that second pass is nulling: delta_piezo is the piezo's own offset from
its 25 V center, converted to Hz — literally (piezo.voltage - 25) × hz_per_v, negated for harmonic linearizers
cavity.py::Cavity.delta_piezo. Driving that to
zero with the stepper walks the piezo voltage back toward center, so the
stepper ends up holding the offset instead of the piezo.
Tolerances¶
50 Hz, or 500 Hz for harmonic linearizers — a literal in the
move_to_resonance call, not derived from anything
cavity.py::Cavity.move_to_resonance “500 if self.cryomodule.is_harmonic_linearizer else 50”.
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::Cavity._auto_tune “self.check_detune()”, cavity.py::Cavity.check_detune. 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::Cavity.find_chirp_range “chirp_range = abs(int(chirp_range))”, so the negative value that check_detune() passes in — chirp_freq_start is
negative by construction
cavity.py::Cavity.set_chirp_range — 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::Cavity.find_chirp_range.
4. Tune states¶
Every cavity carries a TUNE_CONFIG PV asserting what its frequency currently
means
linac_utils.py::TUNE_CONFIG_RESONANCE_VALUE, linac_utils.py::TUNE_CONFIG_COLD_VALUE, linac_utils.py::TUNE_CONFIG_PARKED_VALUE, linac_utils.py::TUNE_CONFIG_OTHER_VALUE.
| State | What it asserts | Written by |
|---|---|---|
RESONANCE (0) |
On resonance, ready for beam | move_to_resonance() on success cavity.py::Cavity.move_to_resonance “put(linac_utils.TUNE_CONFIG_RESONANCE_VALUE)” |
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::Cavity._auto_tune “put(linac_utils.TUNE_CONFIG_OTHER_VALUE)” |
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_COLD |
The reference detune, in Hz, at cold landing cavity.py::Cavity.df_cold_pv |
NSTEPS_COLD |
The signed step count for the return trip, resonance back to cold landing — a distance, not a position stepper.py::StepperTuner.steps_cold_landing_pv, phases/frequency_tuning.py::FrequencyTuningPhase._write_cold_landing_steps |
5. The commissioning stages¶
RF commissioning wraps the same loop in a gated, operator-supervised sequence
phases/frequency_tuning.py::FrequencyTuningPhase.get_phase_steps. 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
phases/frequency_tuning.py::FrequencyTuningLimits.min_probe_delta_hz, enforced phases/frequency_tuning.py::FrequencyTuningPhase._probe_stepper_direction “abs(delta) < self.limits.min_probe_delta_hz”. 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:DFphases/frequency_tuning.py::FrequencyTuningPhase._probe_stepper_direction “A positive number of microsteps decreases CHIRP:DF”. - Waits for the operator before writing. And it writes
SCALE_CALC.B, notSCALE—SCALEis a read-only calc output the IOC recomputes from it (SCALE = SCALE_CALC.B / 256), so writingSCALEdirectly is silently revertedstepper.py::StepperTuner.set_hz_per_microstep, phases/frequency_tuning.py::FrequencyTuningPhase._apply_hz_per_step “STEP:SCALE is a derived, read-only calc-record output”. - Refuses to tune until
DF_COLDis pushed and matches the recorded cold-landing frequency within 1 Hzphases/frequency_tuning.py::FrequencyTuningPhase._check_df_cold_recorded, tolerance at phases/frequency_tuning.py::FrequencyTuningPhase._DF_COLD_MATCH_TOLERANCE_HZ. 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 kelvinphases/frequency_tuning.py::FrequencyTuningLimits.temp_limit_k, raisable for a re-run by an explicit operator acknowledgementphases/frequency_tuning.py::FrequencyTuningPhase._tune_to_resonance “parameters.get("over_temp_ack_k")”. The raised ceiling is passed straight into_auto_tune'smax_stepper_temp, which still fails hard on a breach — the acknowledgement moves the line, it does not add a retry.
Kelvin, despite the _c on those names and the °C in StepperTempError's
message. The stepper sits in the cryomodule insulating vacuum: production
testing interlocks the motor below 70 K and reports it starting near 30 K and
rising under 4 K through a long motion
Holzbauer et al., IPAC2018, WEPML004. The simulation agrees, serving STEPTEMP at 35.0 with its alarm at 70
cavity_service.py::CavityPVGroup.step_temp. The
number is right and the label is wrong.
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
phases/frequency_tuning.py::FrequencyTuningPhase._check_state_for_stage_2, phases/frequency_tuning.py::FrequencyTuningPhase._check_state_for_stage_3, phases/frequency_tuning.py::FrequencyTuningPhase._check_state_for_stage_4
— 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¶
The last column names the fault to pick from Inject a fault in the section 2 simulator, so you can watch the loop react.
| Failure | Raises | Cause and what to do | Simulator fault |
|---|---|---|---|
| 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. |
slip |
| 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::Cavity._auto_tune “if est_steps == 0:”. 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. |
bad_scale |
| 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::Cavity._auto_tune “DetuneError(f"{self} detune invalid")”. |
|
| 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_chirp |
| Detune invalid mid-loop, SELA | DetuneError |
No range to widen, so it fails hard cavity.py::Cavity.check_detune “Cannot tune in SELA with invalid detune”. Auto setup only. |
detune_invalid_sela |
| 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::Cavity._auto_tune “Optional stepper motor temperature guard”. |
hot_motor |
| Limit switch hit | StepperError |
Checked after every completed move — the motor stopped for a bad reason rather than because it arrived stepper.py::StepperTuner.issue_move_command “if self.on_limit_switch:”. |
limit_switch |
| 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::StepperTuner.check_abort, stepper.py::StepperTuner.issue_move_command “while self.motor_moving:”. |
|
| 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::Cavity.check_abort. A caller that stops the stepper without setting this leaves the cavity powered. |
abort |