Skip to content

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 delay delay_s, current-loop bandwidth torque_bandwidth_hz, velocity filter velocity_filter_hz, control rate loop_hz, one encoder count at the output position_quantum_rad, torque_cap_nm, travel, the first structural resonance structural_hz if known, the gravity torque at the working point gravity_load_nm, and sensor noise velocity_noise_rad_s only 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 ​

boundtestwhy
gain margin1/|L(jω)| ≥ 2 at the first −180° crossingkp may double before the loop rings (6 dB)
noise budgetkd capped so kd × velocity noise ≤ 2 % of the cap; damping may fall to 0.5, no lowersensor noise times kd is the torque buzz. Applied only where noise is stated
quantizationkp × one count ≤ 1 % of the capa one-count dither must not become a torque buzz
structural modeωn ≤ ⅓ × 2π·structural_hzstay well under a belt stretch or gearbox resonance
linear rangekp × (1 % of travel) ≤ cap − gravity load − Coulomba 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/s

kd 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:

checkresult
closed form on an ideal joint (no delay, rigid, exact encoder), gains from the true modelthe 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 onset6–27 % below it across 0.5–4 ms of delay: conservative, the safe side
identification vs truthinertia +3 to +15 % (biased high), Coulomb within 6 %; UR5e joints 0.98–1.02× MuJoCo's mass matrix plus armature
friction bandall 288 stops of a PD land inside τ_static / kp
delay calibrationinertia read 0.91–0.95× truth without it, 1.02–1.04× with it
one real direct-drive joint, its sweep's 18 holdspredicted 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.

Source: coming soon.