Appearance
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
| key | meaning |
|---|---|
schema | "pidtuna/proposal-1" |
generated_at | UTC timestamp, ISO 8601 |
servo | the servo's name |
can_id | the servo's bus id if it has one, else null |
changes | true if any drive gain or the feed-forward differs from as found |
board_set | only the drive keys that change, in the drive's own per-revolution units (servo.pid_position.kp, .kd, .ki, .ilimit) |
board_before | the same keys as found |
host_feedforward | the 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, tuned | each controller in full: label, gains_si, board (per revolution), feedforward |
model | the 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, after | the as-found and tuned controllers on the same held-out tapes, as a Score |
verdict | how 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 |
notes | plain-language notes: limits hit, tapes eased, why a candidate was or was not taken |
sweep | one row per sweep rung: wn, zeta, kp, kd, ringing_mrad, crossings, overshoot, chatter_nm, complete, rings, why |
candidates | every 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_setis 2× the as-found kp and kd: the top of the default gain guard. Thenotesline 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
costis relative to as found, which scores 2.0 (stop term 1 + tracking term 1). None was rejected here. On a joint that rings,rejectedlists 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.