Skip to content

The proposal ​

config.proposal(result) turns a TuneResult into one reviewable JSON document: what to write to the drive, what the host must add to each command, what that buys on held-out motion, and how it was found. The CLI saves it as proposal.json. Nothing reads it back into a drive for you.

Top-level keys ​

keymeaning
schema"pidtuna/proposal-1"
generated_atUTC timestamp, ISO 8601
servothe servo's name
can_idthe servo's bus id if it has one, else null
changestrue if any drive gain or the feed-forward differs from as found
board_setonly the drive keys that change, in the drive's own per-revolution units (servo.pid_position.kp, .kd, .ki, .ilimit)
board_beforethe same keys as found
host_feedforwardthe feed-forward the command path must add, SI, or null for feedback only. Drives do not compute friction or gravity feed-forward themselves; the host sends it with each command
baseline, tunedeach controller in full: label, gains_si, board (per revolution), feedforward
modelthe identified joint: inertia_kg_m2, viscous_nm_s_rad, coulomb_nm, gravity_sin_nm, gravity_cos_nm, the fit's residual_nm, condition, rows, source (identified, or prior (...) when identification was refused) and ci95 per term
before, afterthe as-found and tuned controllers on the same held-out tapes, as a Score
verdicthow the pick was reached: friction band, stability onset wn_onset, deploy ceiling wn_max, why the sweep stopped, the calibrated state/torque delay, the ringing and chatter limits used, tuned wn and zeta, whether the stop allocation was met before and after
notesplain-language notes: limits hit, tapes eased, why a candidate was or was not taken
sweepone row per sweep rung: wn, zeta, kp, kd, ringing_mrad, crossings, overshoot, chatter_nm, complete, rings, why
candidatesevery controller the search played: pass, label, wn, zeta, cost, rejected (reasons; empty if admissible), stop_p90_mrad, tracking_rms_mrad, board, feedforward

Non-finite numbers are written as null, and floats are rounded to six decimals.

Example ​

The quickstart joint. Two arrays and one repeated object are trimmed and the layout is compacted; every value shown is as written.

jsonc
{
 "schema": "pidtuna/proposal-1",
 "generated_at": "2026-10-26T19:11:32+00:00",
 "servo": "elbow",
 "can_id": null,
 "changes": true,
 "board_set": {
  "servo.pid_position.kp": 100.531,
  "servo.pid_position.kd": 5.0265
 },
 "board_before": {
  "servo.pid_position.kp": 50.2655,
  "servo.pid_position.kd": 2.5133,
  "servo.pid_position.ki": 0.0
 },
 "host_feedforward": {
  "coulomb_nm": 0.238227,
  "band_rad_s": 0.05,
  "viscous_nm_s_rad": 0.066935,
  "inertia_kg_m2": 0.031051,
  "gravity_sin_nm": 1.200785,
  "gravity_cos_nm": -0.000248,
  "linear": false,
  "ripple_harmonic": 0,
  "ripple_rotor_per_output": 10.0,
  "ripple_cos_nm": 0.0,
  "ripple_sin_nm": 0.0,
  "ripple_keyed_to": "reply"
 },
 "baseline": {
  "label": "as found",
  "gains_si": {"kp": 8.0, "kd": 0.4, "ki": 0.0, "ilimit": null},
  "board": {"kp": 50.265482, "kd": 2.513274, "ki": 0.0},
  "feedforward": null
 },
 "tuned": {
  "label": "tuned",
  "gains_si": {"kp": 16.0, "kd": 0.8, "ki": 0.0, "ilimit": null},
  "board": {"kp": 100.530965, "kd": 5.026548, "ki": 0.0},
  "feedforward": { /* the same as host_feedforward */ }
 },
 "model": {
  "inertia_kg_m2": 0.031051,
  "viscous_nm_s_rad": 0.066935,
  "coulomb_nm": 0.238227,
  "gravity_sin_nm": 1.200785,
  "gravity_cos_nm": -0.000248,
  "band_rad_s": 0.01,
  "residual_nm": 0.016392,
  "condition": 4.306239,
  "rows": 2201,
  "source": "identified",
  "linear": false,
  "ci95": {
   "inertia_kg_m2": [0.030818, 0.031418],
   "viscous_nm_s_rad": [0.059187, 0.074989],
   "coulomb_nm": [0.232623, 0.242858],
   "gravity_sin_nm": [1.182935, 1.224413],
   "gravity_cos_nm": [-0.001724, 0.001426]
  }
 },
 "before": {
  "stop_p50_mrad": 19.31965, "stop_p90_mrad": 32.48213, "stop_max_mrad": 71.381515, "stops": 32,
  "tracking_rms_mrad": 31.25381, "max_error_mrad": 87.14117,
  "ringing_mrad": 0.0, "chatter_nm": 0.0, "saturation": 0.0, "overshoot": 0.0, "complete": true,
  "by_class": {"cycle": 31.206925, "jog": 22.261442, "pickplace": 22.346494, "sweep": 50.029909},
  "crossings": 0
 },
 "after": {
  "stop_p50_mrad": 2.477645, "stop_p90_mrad": 3.758522, "stop_max_mrad": 4.09658, "stops": 32,
  "tracking_rms_mrad": 2.872567, "max_error_mrad": 6.284991,
  "ringing_mrad": 0.0, "chatter_nm": 0.0, "saturation": 0.0, "overshoot": 0.01334, "complete": true,
  "by_class": {"cycle": 3.49691, "jog": 1.65862, "pickplace": 3.62266, "sweep": 1.88163},
  "crossings": 0
 },
 "verdict": {
  "friction_band_mrad": 29.77836,
  "coulomb_nm": 0.238227,
  "allocation_mrad": null,
  "feedforward": "FF (Coulomb x1, viscous, inertia, gravity) + FB",
  "feedback_only": false,
  "wn_as_found": 16.051206,
  "zeta_as_found": 0.468429,
  "wn_onset": null,
  "wn_max": 22.699833,
  "sweep_stop": "reached the gain guard without ringing",
  "torque_delay_ms": 7.651863,
  "delay_calibration": "7.65 ms from J at kd 0.24 vs 0.64",
  "ringing_threshold_mrad": 0.05,
  "chatter_limit_nm": 0.12,
  "wn_tuned": 22.699833,
  "zeta_tuned": 0.614978,
  "meets_allocation": null,
  "met_allocation_before": null
 },
 "notes": [
  "No ringing up to the gain guard (2x the as-found gains): the stability limit lies beyond it, so a second session after writing these gains could go further."
 ],
 "sweep": [
  // ... 2 earlier rungs
  {"wn": 22.699833, "zeta": 0.614978, "kp": 16.0, "kd": 0.8, "ringing_mrad": 0.0, "crossings": 1, "overshoot": 0.0,
   "zeta_seen": null, "chatter_nm": 0.0, "complete": true, "rings": false, "why": []}
 ],
 "candidates": [
  {"pass": "as found", "label": "as found", "wn": 16.051206, "zeta": 0.468429, "cost": 2.0, "rejected": [],
   "stop_p90_mrad": 52.482581, "tracking_rms_mrad": 35.148895,
   "board": {"kp": 50.265482, "kd": 2.513274, "ki": 0.0}, "feedforward": null},
  // ... 13 more
  {"pass": "gains", "label": "wn 22.7 zeta 0.61, FF (Coulomb x1, viscous, inertia, gravity) + FB",
   "wn": 22.699833, "zeta": 0.614978, "cost": 0.117426, "rejected": [],
   "stop_p90_mrad": 2.929212, "tracking_rms_mrad": 2.16563,
   "board": {"kp": 100.530965, "kd": 5.026548, "ki": 0.0}, "feedforward": { /* as above */ }}
  // ... 5 more (20 in all)
 ]
}

How to read it:

  • board_set is 2× the as-found kp and kd: the top of the default gain guard. The notes line says the sweep never found ringing, so a second session after writing these gains could go further.
  • The candidates' stop p90 (2.93 mrad for the pick) comes from the search tapes. after (3.76 mrad) comes from held-out tapes. The second number is the claim.
  • A candidate's cost is relative to as found, which scores 2.0 (stop term 1 + tracking term 1). None was rejected here. On a joint that rings, rejected lists the reason, for example "rings 3.1 > 2 mrad".

Arm proposals ​

config.arm_proposal(arm_result) (and arm-sim) writes schema, generated_at, arm (the arm's name), joints (one proposal as above per joint name), arm_before and arm_after (each joint's Score with the whole arm moving), and notes (back-offs and fallbacks). Each joint's own before/after is also the whole-arm run, not its single-joint result.

Applying it ​

The drive gets board_set, in per-revolution units; a drive that takes SI gains wants tuned.gains_si instead. Your command path adds host_feedforward to each command, evaluated at the reference: Feedforward(**doc["host_feedforward"]).torque(q_ref, qd_ref, qdd_ref). Keep board_before as the rollback.

Source: coming soon.