Appearance
The pipeline
text
Servo ──► 0 analytic ──► 1 identify ──► 2 sweep ──► 3 refine ──► 4 verify ──► proposal
from the J, b, τc, ωn ×1.2 per feed-forward held-out
description gravity rung until a × (ωn, ζ) tapes, every
(moves nothing) hold rings grid gate againStage 0 is optional and needs no motion. The playground runs these stages live on a single axis in the browser. Stages 1–4 move the joint, always through the Plant seam. The tuner works in bandwidth and damping (ωn, ζ) rather than raw (kp, kd), so one search and one ringing rule serve a 6.6e-5 kg·m² direct drive and a 3.6 kg·m² arm shoulder alike.
The motion it plans for the quickstart joint:
python
from pidtuna import Envelope, Gains, Servo, tapes
from pidtuna.tune import plan_seconds
servo = Servo(name="elbow", gains=Gains(kp=8.0, kd=0.4), torque_limit_nm=6.0,
envelope=Envelope(travel_rad=0.4, max_speed_rad_s=2.0, max_torque_nm=6.0, origin_rad=0.0))
for tape in tapes.identification(servo) + [tapes.steps(servo)] + tapes.evaluation(servo, seed=0):
print(f"{tape.name:<14} {tape.kind:<9} {tapes.duration_s(tape):5.1f} s")
print({stage: round(seconds) for stage, seconds in plan_seconds(servo).items()})text
ident-sweeps waypoints 17.8 s
ident-fast waypoints 5.6 s
ident-chirp chirp 21.0 s
steps waypoints 6.0 s
pickplace-s0 waypoints 6.5 s
cycle-s0 waypoints 5.2 s
jog-s0 waypoints 5.5 s
sweep-s0 waypoints 3.4 s
{'identify': 44, 'sweep (worst case)': 84, 'refine': 1026, 'verify': 82}The last line is an upper bound in seconds of motion: about 20 minutes on hardware for this joint, most of it the search.
Stage 0: analytic start
A closed-form controller from the robot's description, at the highest bandwidth five bounds allow. It measures and moves nothing. Passed to tune as seed, it sets where the sweep starts, it joins the search, and it is the incumbent at verify. The analytic start.
Stage 1: identify
Fit the joint model
τ = J·q̈ + b·q̇ + τc·tanh(q̇ / 0.01) + gs·sin q + gc·cos q
by least squares on the torque the controller requested. On a linear axis gravity is a constant force. Three tapes excite every term: plateaus at 20–100 % of 80 % of top speed both ways (Coulomb vs viscous), short fast moves (inertia), and a chirp up to min(2 Hz, command rate / 50). Above that rate the torque swings inside every command interval and the rows stop obeying J·q̈ = τ.
- Rows used: moving (|q̇| ≥ 0.02 rad/s across the whole derivative window) and below 98 % of the torque cap. q̇ and q̈ come from a local quartic fit over 110 ms of position, not from the drive's velocity estimate.
- Torque per interval: a drive that advances its setpoint at the commanded velocity between commands reports torque at one instant of an interval that drifts. The fit corrects to the interval mean (using the plant's
torque_sample_phase) and pairs it with the state at mid-interval. Uncorrected, this reads as +40 % inertia on a geared joint at 100 Hz. - State/torque delay: the same chirp is played at kd ×0.6 and ×1.6. An unmodelled delay δ makes the apparent inertia J − kd·δ, so δ is bisected in [0, 20 ms] until both fits agree.
- Softer re-identification: if the loop is too fast for the command rate to see (ωn·Δ > 0.5, Δ the command interval), identify again at (0.5 / ωn·Δ)² of the gains, up to twice, never below the guard or soft enough to sag a quarter of the travel.
- Refusal: under 50 usable rows, or a condition number above 100 even after dropping gravity, falls back to
servo.priors. If a seed exists, the tuner first retries at the seed's gains: factory gains inside the friction band may never move the joint. - Intervals: 95 % bootstrap intervals per term (20 blocks, 60 resamples).
Before the sweep, the tuner sets its limits from the as-found run. The ringing threshold is Goal.ringing_mrad if set, else the largest of 3× the joint's own hold noise, 0.05 mrad and 1.5× the as-found ringing; either way it is capped at ringing_cap_mrad (2 mrad). The chatter limit is the larger of 2 % of the torque cap and 1.5× the as-found chatter. Every tape is eased until the identified model can follow it on 80 % of the cap (moves stretched, or shrunk if gravity alone is too much).
Stage 2: sweep
Ziegler–Nichols' "turn it up until it oscillates, then back off", one guarded rung at a time:
- The step tape is six steps into 0.8 s holds. Most are as sharp as two command intervals allow; two larger ones (15 % of travel) cross the friction band so a sticky joint still moves.
- Start at the seed's ωn (else the as-found ωn), clipped to the guard: ωn ceiling = √(kp_max / J).
- Each rung:
kp = J·ωn²,kd = 2ζ·ωn·J − b(ζ = 0.8, kd ≥ 0), clipped into the guard. If kd hits the guard, ζ falls and a note says so. - Next rung ωn ×1.2, at most 14 rungs. Stop at the first rung that rings past the threshold, crosses the target 4 or more times in one hold, aborts, or chatters past the limit. A chatter-only stop must repeat on a second run.
- If the first rung already rings, walk down ÷1.2 until quiet.
The deploy ceiling is set from the last quiet rung: ωn_max = ωn_quiet / √gain_margin. That is half the last quiet rung's kp, about 0.35× the first ringing rung's kp. It starts from the quiet rung because at ~100 Hz rows an instability is only visible once it has started, one rung past the real limit.
Two exceptions. If the stop was chatter alone and it grew like sensor noise through kd (chatter ratio ≤ 2× the kd ratio), the ceiling is the last quiet rung itself: that is a noise limit, not an approach to instability. If nothing rang, the ceiling is the guard, and a note says another session after writing could go further. The ceiling binds every later candidate, the seed included.
Stage 3: refine
Three coordinate passes on the evaluation tapes (seed 0: pick-and-place, out-and-back cycles, small jogs, one slow sweep), each controller played Goal.repeats times:
- Feed-forward at the as-found gains: feedback only, and the model's feed-forward with Coulomb at 0, 0.25, 0.5, 0.75, 1× (viscous, inertia and gravity on), cut to the terms the plant can send. The seed, and the seed's gains under each feed-forward, join here.
- Gains at the best feed-forward: ωn in {as found, 0.6, 0.8, 1.0 × ωn_max, the seed's} × ζ in {0.65, 0.8, 1.0}, all at or under the ceiling and inside the guard.
- Feed-forward again at the best gains.
A candidate must pass every gate against the as-found run: complete with finite telemetry and at least one stop; ringing, chatter and saturation (≤ 2 %, or ≤ 1.1× as found) within limits; tracking ≤ 1.1× as found; stop p90 within the allocation (or no worse than as found); ωn ≤ ωn_max. The terms are defined in scoring. Among admissible candidates, the cost is
cost = stop_p90 / max(stop_p90_as_found, 0.1·allocation, 0.05 mrad) + tracking_rms / max(tracking_rms_as_found, 0.05 mrad)
so as found scores 2. Feedback alone is kept unless a feed-forward beats it by 10 %: a feed-forward is one more model that can drift. The pick must beat as found by 10 % or as found stays.
Stage 4: verify
The as-found controller, the pick and the analytic start replay held-out tapes from a seed no stage has seen. Every gate is applied again. A controller must beat as found by 10 % in cost. The analytic start is the incumbent: the search's pick replaces it only if it is 10 % better than the start too. If nothing passes, the as-found controller stays and TuneResult.improved is false.
Proposal
config.proposal(result): drive gains in the drive's units, host feed-forward, the model, before/after on the held-out tapes, every rung and candidate. The proposal.
The arm check
tune_arm tunes each joint tip to base, so a proximal joint is tuned while carrying distal joints that already behave. While one joint tunes, the others hold home. One level up, the robot's nominal model (MuJoCo mj_inverse: gravity, link inertia, coupling) adds feed-forward to every command, and each joint's own box adds only Coulomb and viscous. On a closed chain or with passive joints that model feed-forward is off, and each box carries gravity and inertia too.
Then all joints play four held-out pick-and-place tape sets at once, each joint's tape eased to what it can follow. A joint offends if:
- it is above its sweep's deploy ceiling;
- it rings past 2 mrad (or
Goal.ringing_mrad) over stretches where every target is still. These are found per joint, intersected in time, and each joint is measured on its own samples. A changed joint never seen that way is not delivered; - while partners move, it rings past the cap and still does when replayed with its partners holding still (then the ringing is its own), or it rings past both the cap and 1.5× what the as-found arm shows on the same tapes. Below the cap, a changed joint ringing past its own threshold counts only if that is over 1.5× what its as-found controller shows in the same company: a joint pushed by its partners rings more when softened, not less;
- it aborts, saturates beyond max(2 %, 1.1× the same tape set's as-found run), chatters past its limit, or its cost is worse than as found;
- once anything in the arm changed, it breaks the single-axis stop or tracking rule. This applies to every joint, changed or not.
Offenders back off ωn ×0.8 per round, at most five rounds, never above their ceiling. If an offender kept its own controller, the joints that changed back off instead, because their new gains are what moved it. If offenders remain, the result is the best whole combination tried that left no joint worse than as found, else the whole arm as found. Every joint's before/after is the whole arm's run.
Fine-tuning a deployed joint
fine_tune(servo, deployed, logs, twin) uses motion the robot already made:
- Score the logs as they ran.
- Drift: re-identify from the same rows and report each term's change against the commissioning model.
- Learn: feedback-error learning. The feedback torque (τ − τ_ff) is what the feed-forward is missing, so regressing it on the reference's own regressor gives the correction.
- Twin: rebuild the joint from the new model and check it reproduces the logs' tracking error within 30 %. Then search a trust region around the deployed controller on the logged tapes: ωn ×{0.85, 1, 1.15}, ζ ±0.1, deployed or learned feed-forward, Coulomb ×{0.75, 1, 1.25}.
- Propose only a predicted 10 % improvement.
canary(after_logs)keeps the change if the live cost ratio is at most1 − ½(1 − predicted), halfway to the prediction, else says roll back.