Appearance
The analytic start
analytic.analytic_controller(model, loop) computes a controller from what the robot's description already knows, before the joint moves. It is the starting point the tuner then measures and improves, and the baseline it has to beat.
The closed form
Treat the joint as J·q̈ + b·q̇ = τ and pick PD gains so the error obeys ë + 2ζ·ωn·ė + ωn²·e = 0:
kp = J·ωn²kd = 2ζ·ωn·J − b, never below 0 (a joint's own viscous friction can already over-damp it)
ζ is Goal.damping_ratio, 0.8 by default. The closed form is exact only for that model. The real work is choosing ωn: the start takes the largest ωn whose gains pass all five bounds, found by bisection on log ωn between 0.5 and 5000 rad/s. The feed-forward is the model's own: Coulomb (at coulomb_fraction), viscous, inertia and gravity.
Inputs: the description, not measurements
PlantModel: inertia from CAD or the robot's mass matrix plus the datasheet rotor inertia, friction from the datasheet if the description has it (none otherwise), gravity from CAD.LoopModel: how the loop is configured, not identified. Command and measurement delaydelay_s, current-loop bandwidthtorque_bandwidth_hz, velocity filtervelocity_filter_hz, control rateloop_hz, one encoder count at the outputposition_quantum_rad,torque_cap_nm,travel, the first structural resonancestructural_hzif known, the gravity torque at the working pointgravity_load_nm, and sensor noisevelocity_noise_rad_sonly if the description states it.
It measures nothing. Earlier versions estimated sensor noise from a second of standing still. A hidden load leaked through that reading (a sag, then a lightly damped settle, both read as noise), so the measurement was removed. Tests now run the start on a robot that refuses every motion, and check that doubling hidden friction or rotor inertia, or changing a hidden payload, leaves it unchanged.
The five bounds
| bound | test | why |
|---|---|---|
| gain margin | 1/|L(jω)| ≥ 2 at the first −180° crossing | kp may double before the loop rings (6 dB) |
| noise budget | kd capped so kd × velocity noise ≤ 2 % of the cap; damping may fall to 0.5, no lower | sensor noise times kd is the torque buzz. Applied only where noise is stated |
| quantization | kp × one count ≤ 1 % of the cap | a one-count dither must not become a torque buzz |
| structural mode | ωn ≤ ⅓ × 2π·structural_hz | stay well under a belt stretch or gearbox resonance |
| linear range | kp × (1 % of travel) ≤ cap − gravity load − Coulomb | a small error must not saturate the torque left after gravity and friction |
The loop for the gain margin is the PD (velocity through the filter and a half-sample delay) times the delay, the current loop and the joint:
L(s) = (kp + kd·s·e^(−s/(2·loop_hz)) / (1 + s/ωv)) · e^(−s·(delay + 1/(2·loop_hz))) / (1 + s/ωτ) / (J·s² + b·s)
evaluated on a log grid from 0.3 to 10⁵ rad/s.
Each bound came from a failure without it. On gain margin alone, the start asked a lever for 760× its drive's kp, and a belt conveyor for kp 365,000 N/m, above its belt's stretch resonance. Against the whole cap instead of the headroom after gravity, a lever holding 4 N·m under a ~5 N·m cap saturated 14 % of its rows.
Which bound binds
info["binding"] names it. Here every bound is stated, and the start stops at the quantization wall:
python
import math
from pidtuna import Goal, PlantModel
from pidtuna.analytic import LoopModel, analytic_controller, candidate, limits
model = PlantModel(inertia_kg_m2=0.028, viscous_nm_s_rad=0.05, coulomb_nm=0.2, gravity_sin_nm=1.1, source="CAD + datasheet")
loop = LoopModel(delay_s=0.0005, torque_bandwidth_hz=1000.0, velocity_filter_hz=400.0, loop_hz=4000.0,
velocity_noise_rad_s=0.05, position_quantum_rad=2 * math.pi / 16384, torque_cap_nm=6.0,
structural_hz=60.0, travel=0.4, gravity_load_nm=1.1)
goal = Goal()
controller, info = analytic_controller(model, loop, goal)
print(controller.label)
print(f"kp {info['kp']:.1f} N·m/rad, kd {info['kd']:.3f} N·m·s/rad, gain margin {info['gain_margin']:.2f}")
# Where each bound alone would stop wn (bisection on log wn, as the start itself does).
for bound in ("gain margin", "damping", "quantization", "structural mode", "linear range"):
low, high = math.log(0.5), math.log(5000.0)
for _ in range(40):
middle = 0.5 * (low + high)
passes = limits(model, loop, candidate(model, loop, math.exp(middle), goal.damping_ratio), margin=goal.gain_margin)[bound]
low, high = (middle, high) if passes else (low, middle)
print(f" {bound:<16} wn <= {math.exp(low):6.0f} rad/s")text
analytic (wn 74.8, zeta 0.59, bound by quantization)
kp 156.5 N·m/rad, kd 2.400 N·m·s/rad, gain margin 15.18
gain margin wn <= 303 rad/s
damping wn <= 88 rad/s
quantization wn <= 75 rad/s
structural mode wn <= 126 rad/s
linear range wn <= 205 rad/skd sits at its noise budget (0.02 × 6 N·m / 0.05 rad/s = 2.4), which is why ζ came out at 0.59 rather than 0.8. ("damping" is the noise-budget bound's name in the code.)
Across the 33 simulated joints in the evaluation, the binding bound was gain margin on 22 (every finger and two arm wrists), linear range on 7, quantization on 3 and the structural mode on 1 (the conveyor). The noise budget bound none of them, since those descriptions state no noise.
From start to tuned
Pass the start to tune(..., seed=controller) (example). The seed's gains are clipped into the servo's gain guard, its feed-forward is cut to what the plant can send, the sweep starts at its bandwidth, and at verify it stays unless the search beats it by 10 % on held-out motion. On the simulated robots the start alone beats factory gains on all 33 joints; tuning then improves on it by more than 10 % on 22, matches it within 10 % on 10, and is worse on 1 (the lever, where the start rides the torque cap and the gates refuse it).
Does the math hold?
docs/validate.py checks each equation against simulated joints whose truth is known exactly: six single-axis stand-ins and a UR5e in MuJoCo. Numbers from a fresh run:
| check | result |
|---|---|
| closed form on an ideal joint (no delay, rigid, exact encoder), gains from the true model | the step response matches the predicted curve within 0.6 % RMS at ζ 0.5, 0.8 and 1.0. Fitted ζ within 0.005, fitted ωn within 1 % (one critically damped case, where an ωn fit is ill-posed, read 16 % off with the curve still within 0.03 %) |
| closed form on the as-built stand-ins (gearbox, current loop, quantized filtered velocity) | curve mismatch median 3 %, worst 10 %; the same with gains from the identified model |
| analytic stability limit vs the simulated onset | 6–27 % below it across 0.5–4 ms of delay: conservative, the safe side |
| identification vs truth | inertia +3 to +15 % (biased high), Coulomb within 6 %; UR5e joints 0.98–1.02× MuJoCo's mass matrix plus armature |
| friction band | all 288 stops of a PD land inside τ_static / kp |
| delay calibration | inertia read 0.91–0.95× truth without it, 1.02–1.04× with it |
| one real direct-drive joint, its sweep's 18 holds | predicted settling curve off by a median 28 % with the ideal equation, 9 % once a 7 ms state/torque delay (measured on another real joint) and its 100 Hz current loop are included. Deliberately wrong gains: 70 % |
Known weakness
A gain margin of 2 is the floor of the textbook range, and it says nothing about phase margin. On the 22 gain-margin-bound starts above, the start's own loop model gives a phase margin of 24–28° and a sensitivity peak Ms of 2.7–2.9, outside the usual 30–60° and Ms ≤ 2. The realized damping is then lower than the ζ = 0.8 the start reports in info["zeta"]. The sweep and the gates judge the real response, so a start that rings past the limit is refused at verify. See limits.