Stiction compensation, 0xA4 wire mode, and firmware-loop tuning for the X8 shoulders - #317
shawnpatel wants to merge 81 commits into
Conversation
…he X8 shoulders Slow teleop moves on the right arm stair-stepped at ~2 Hz. Traces and a new static-friction probe pin it on the RMD-X8-P20 shoulder gear trains: breakaway 2.4-3.3 Nm under 10-15 Nm of gravity load against 0.66 Nm at rest, sliding friction falling from 2.1 Nm at 0.05 rad/s to 0.7 Nm at 0.2 rad/s (velocity- weakening, ~-7 Nm·s/rad), so a slow slide is unstable under any feedforward. Controller side (all opt-in, production law unchanged at the defaults): - JointConfig stiction_gain / stiction_load_gain / stiction_err_deg: an error-sign Coulomb push, sized gain·fc + load_gain·|gravity|, fading on measured velocity. Cut right shoulder_1's stairs 4x (0.58° -> 0.14°) and halved shoulder_2's error on the slow_osc replay; safe at rest. - dither_nm / dither_hz: sinusoidal torque dither, golden-angle staggered per joint. 2 Nm took 40 % off the 1-4 Hz band, did not remove the cycle. - wire_mode a4: the core commands a MyActuator joint with 0xA4 (firmware position loop, speed-capped) plus a paired 0x92 read for 0.01° position; measured torque reads NaN on such joints. Stock firmware gains gave 600 ms lag and the same 2 Hz velocity cycle; 3x speed_kp vibrated. - Config protocol 2 -> 6 (stiction, load gain, dither, wire token); trace gains stiction_ff and dither_ff columns; Rust math golden-pinned to Python. Tools: - axol tune.breakaway: static-friction probe (kp = 0 hold, trimmed gravity feedforward, escalating torque ramps, both directions). - axol tune.a4: sine / constant-speed triangle over 0xA4 with per-run firmware gains (RAM unless --persist, restored after), planner acceleration, a buzz guard, and creep-smoothness metrics; registered in the serve catalog with a "Firmware loop" workbench tab. - tune.motion --arms and --a4, and stiction/dither fields in --gain. - scripts/creep_test.py and scripts/fw_gains.py for standalone probing. - tuning/motions/slow_osc.npz: the recorded slow motion every A/B replayed. Web lint/tsc not run here (no Node on the robot); CI covers it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6622d12. Configure here.
| print(" previous gains restored") | ||
| before_gains = None | ||
| here = motor.position | ||
| await _hold(driver, motor, here, args.cap, 0.5) |
There was a problem hiding this comment.
Post-run hold uses stale position
Medium Severity
After the 0xA4 stream (or a buzz/tracking abort), _hold commands motor.position, the MIT cache last filled during the pre-wave ramp. The stream talks to the driver directly and never refreshes that cache, so the joint is sent back to the old pose at the --cap speed instead of holding the last 0x92 sample.
Reviewed by Cursor Bugbot for commit 6622d12. Configure here.
| pending -= 1; | ||
| a4_stage[slot] = (speed, iq); | ||
| } | ||
| continue; |
There was a problem hiding this comment.
A4 fallback skips position publish
Medium Severity
If the follow-up 0x92 send is dropped, expected stays 1 and the 0xA4 echo is treated as a complete sample, but that path only stages speed/iq and never writes latest. The joint is marked feedback_fresh on stale position. A 0x92 that arrives before the echo also applies the previous tick’s speed.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6622d12. Configure here.
| got = await _write_plan_accel(driver, original_accel[0]) | ||
| print( | ||
| f" position planner restored: accel {got[0]}, decel {got[1]} dps/s" | ||
| ) |
There was a problem hiding this comment.
Creep test restores accel too late
Low Severity
Teardown homes the joint before writing back planner accel/decel, and _write_plan_accel applies only original_accel[0] to both registers. A --accel 0 run therefore returns to rest in direct-tracking mode, and a motor whose stored accel and decel differed is not fully restored.
Reviewed by Cursor Bugbot for commit 6622d12. Configure here.
…aseline The seven firmware gain fields on the tune.a4 workbench tab read "stock", which says nothing about where to move from. The serve API already returns a motor's live loop gains in its motor.info readout, so the tab now fetches them whenever arm and joint are picked (and again after a run ends), shows "motor 0.008" next to each label, seeds a tightly ranged slider there, and uses the value as the box placeholder. Gains print at four significant digits so 0.0001 does not render as 0.00. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ide table The new firmware-gain formatter collided with the override table's fmtGain (TS2393 on the Vercel build); it is fmtFwGain now, and the gain-box baseline text lives in one helper instead of a nested ternary. The tune.motion override table also gains stiction_gain, stiction_load_gain and dither_nm columns, fed by /api/tuning/gains, so those terms can be A/B'd from the UI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it 30 A The raw 0xA4 stream never fills the driver's position cache, so the post-abort hold read a position that was not there and the run died before scoring. The 10 A default current limit tripped on a loaded shoulder's gravity current alone (right shoulder_1 at -54° holds ~10 A); it is 30 A now and documented as pose-dependent. The stored planner acceleration is printed at the start of every run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e accel baseline The motor readout now carries the 0xA4 position planner's stored accel/decel (MyActuator only, via a new get_planner_acceleration on the driver), and the workbench's planner-accel field shows it as "motor N" beside the label with the value as its placeholder, the same way the loop gains do, so a 0 left in a joint is visible before a run and can be edited from the same box. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tion-periodic torque The sweep's CSV averaged 40 bins over the joint range (~3° each), too coarse to see a sub-degree cogging or gear-mesh period. --raw-csv writes every cruise sample; cogging_map.py averages fwd/bwd passes on a fine angle grid, detrends, and analyses the residual in the angle domain, reporting spatial peaks per speed and the cross-speed correlation (cogging repeats, stick-slip does not) and optionally writing a cancellation table. Verified on a synthetic sweep with a planted 0.94° / 0.4 Nm ripple (recovered at 0.93°, r = 0.92 across speeds). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pass to pass Three passes at one speed are the strictest repeatability test for a position table, and the analysis merged them by speed. The raw CSV now records the pass index and the analysis keys on (pass, speed). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… friction Two feedforward terms the stick-slip data asks for, both default-off: - stribeck_gain / stribeck_dfs / stribeck_load_gain / stribeck_vs: the excess of low-speed over sliding friction, keyed on the *measured* velocity (20 rad/s low-pass of position in the core) with the measured curve's shape, amp·exp(-(v/vs)²)·tanh(v/0.02). Every earlier feedforward was a function of the commanded velocity or of position and so could not touch the velocity-weakening slope that drives the 2 Hz cycle; this one follows the real speed and flattens that slope. Zero at rest. Trace column stribeck_ff. - friction.fl: Coulomb level fc + fl·|gravity|. The breakaway probes on both branches found a constant fc over-states friction at rest and under-states it at reach; tune.friction now fits the slope when its sweep spans >= 3 Nm of load (today's 3-8 deg/s sweep on right shoulder_1: Fc0 0.83 Nm, Fl 0.017 Nm/Nm over 7.8 Nm), and saves it. - tune.motion --gain accepts joint.friction.fc/k/fv/fo/fl. Config protocol 6 -> 8; Rust math golden-pinned to the Python originals. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… (proto 9) The first A/B (gains 0.5/0.7 on the right shoulders) left velocity ripple unchanged: the term arrived ~40 ms behind the 2.6 Hz surge through its fixed 20 rad/s velocity filter and cancelled only ~2.6 of the ~7 Nm·s/rad friction slope. The pole is now a per-joint setting so lag can be traded against encoder-step noise (40-80 rad/s) on the same replay. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Stribeck velocity filter takes its pole from the config spec when the per-slot filter state is built, so the per-motor copy was never read (dead_code warning on rt.install). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the run's gains A 0xA4 elbow probe ended with the forearm rising toward rest and then dropping: the teardown restored the stock firmware gains first, asked the loaded joint to climb home on the stock position loop (which the other branch had already found cannot hold a loaded elbow), swallowed the failed arrival, and then reset + disabled — torque-off with the joint still under gravity. tune.a4 now homes on the run's gains and restores them afterwards (the planner first, when a run left it at 0), and tune.a4, tune.friction and tune.breakaway share _safe_torque_off, which reads every joint and refuses the reset/disable unless all are within 5° of rest, leaving the motors holding and telling the operator what to do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
wire_mode a4 needs both the firmware gains in ROM (the core's bring-up reset reloads them) and planner acceleration 0 on the joint; one command now does both and warns about the stored-target-on-wake behaviour a 0 leaves behind. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The X8-P20 shoulders stick-slip at creep speed on their stock 0xA4 loop (position_kp 0.008, ~0.3 Hz): 2.6 Hz velocity cycle, 0.5° stairs, 38 % of a 3 deg/s pass stuck. The tune.a4 sweeps on right shoulder_1 found the knee at position_kp 0.3 with speed_kp 0.1 — stairs gone, velocity ripple 0.26 vs 0.86, zero stuck windows, >20 Hz current still at the stock floor — while 0.5 starts a ~5 Hz loop mode and 1.0 buzzes. speed_ki is *lowered* to 1e-5: the integrator winds up while the joint is stuck and dumps it at release, so the stock 1e-4 feeds the surge (2e-4 limit-cycled at 5 Hz, 6 A). position_kd is carried at stock; 0.1/0.3/0.6 produced identical traces, the firmware stores it but the 0xA4 loop ignores it. JointConfig gains a `firmware` block (FirmwareGains: position_kp/ki/kd, speed_kp/ki, all None = leave the motor alone); shoulder_1 and shoulder_2 default to the set above on both arms. MyActuatorMotor.ensure_rom_gains reads each gain, writes 0x32 only when it differs beyond float32 rounding, settles and reads back, refusing pre-V4.2 firmware. Both enable paths (classic AxolArm bring-up and the realtime core's _enable) apply it to the cold joints while they are still disabled — the only state a MyActuator commits a ROM write in — and the bus is quiet; held joints are never touched and a refusing motor warns instead of failing the enable. Arms built without a config (bench/test) skip it. The gains only act under wire_mode a4; the MIT frame ignores them. Also: calibration-file `firmware` overlay, FirmwareGains export, config docs rows, tune.a4 note, tests/test_firmware_gains.py. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tune.motion has taken `--a4 SIDE.JOINT` (repeatable) since 6622d12: the named MyActuator joint runs the replay on the firmware's 0xA4 position loop while every other joint stays on the MIT impedance frame, so "shoulder_1 on the firmware loop, everything else on impedance" can be A/B'd against the identical motion. The CLI was the only way to reach it — the workbench's Recorded-motion tab had no control for it. The tab now carries a controller-per-joint grid (rows: the five MyActuator joints; columns: left/right; each cell toggles impedance ↔ firmware). It serializes to the `side.joint` token string the CLI takes, which the server already fans out into one `--a4` per token, so the launch path is unchanged. Cells the robot's config pins to `wire_mode a4` show as firmware and cannot be switched back (a run can add `--a4` joints, not remove them); for that the gains endpoint now also reports each joint's configured `wire_modes`. tune.motion records its `--a4` list in the run params, so clicking a saved run re-arms the same controller split. Helpers live in web/app/src/lib/wire-mode.ts with node tests; the dashboard guide describes the grid. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… blocks Lead: on the 240 Hz half-rate lane the right elbow's planner tracked the slow_osc replay but put ~half its speed error at 60-120 Hz and doubled its >15 Hz position roughness vs direct tracking (0.017 vs 0.008 deg): it reaches a target that sits exactly on the trajectory before its 4.2 ms step ends and stops for the rest of it. FirmwareGains.planner_lead_ms (0-50, host side like cap_track, never written to the motor) makes the core send each 0xA4 target that far ahead along the tracker velocity (a4_target), so the planner cruises through its step. Optional trailing joint-line field; CONFIG_PROTO 13 -> 14. tune.motion --gain firmware.planner_lead_ms and a dashboard grid column. Config: JointConfig now copies its friction and firmware blocks on construction. The per-type defaults were module-level instances shared by every matching joint, so setting a field on one joint - a test here, a tune.motion override before c60f936 - reached every joint and every later config built in the process. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…figured apply_firmware_gains writes only cold joints: a MyActuator takes a ROM write only while disabled, and a joint found holding from a previous session is attached to, never reset. So a joint left holding by a cut run kept running that run's gains, and the next run silently tested the wrong ones (right shoulder_1 kept its previous speed_kp through a speed_kp sweep, 2026-09-22; earlier a joint held on position_kp 2.0 vibrated a run that asked for 1.4). Before the core arms, each held joint's running firmware gains are read (firmware_gain_mismatches: 0x30 gains and the 0x42 planner on MyActuator, register reads on Damiao - reads work while holding) and compared with the configured set; any difference fails the bring-up with the joint, the running and wanted values, and the fix (power-cycle or disable the arm). A joint whose gains cannot be read is skipped with a warning. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Only shoulder_1 and the elbow run on 0xA4 (--a4 in the mixed setup); the rest run on impedance, where the firmware position loop is unused, yet the config wrote the position-controller tunings to their ROM at every cold enable (and a leaked test planner reached shoulder_2). At the operator's request they go back to stock, written at the next cold bring-up: - shoulder_2 (X8-P20): position_kp 0.008, speed_kp 0.03, speed_ki 1e-4, position_kd 0.1 - shoulder_3 / wrist_1 (X6-P20): position_kp 0.06, speed_kp 0.01, position_kd 0.5 (stock speed_ki never recorded: left as held) - wrist_2 / wrist_3 (Damiao): KP_APR 54, profiler 2 rad/s^2 planner_accel stays pinned at 0 on the MyActuator joints. The tuned sets remain defined for the position controller, which would run these joints near-limp on stock. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Values typed for one joint - its kp/kd/kd_host, firmware gains, planner accel, the joint-frame centre, the Damiao ACC/DEC - stayed in the form when the arm or joint select changed, so the next run silently applied them to the new joint. setValue now drops a tab's per-joint fields (those with a gainKey or fwGainKey, plus center and dm_acc) when arm or joint changes, and each box falls back to the new joint's config / live-motor baseline shown as its placeholder. Re-arming from a saved run and tab switching replace the form directly and are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…loor of 10 Since planner_accel was pinned to 0 (d0ab76e), every enable failed to apply right shoulder_1 and shoulder_2's firmware gains: the X8-P20's 2026042402 firmware keeps the planner deceleration at 10 dps/s when told 0, the read-back check required 0/0, and apply_firmware_gains skipped the joint's whole set with a warning. The speed_kp sweep's shoulder_1 value, for one, never reached the motor (the run changed only the elbow). The acceleration is what selects direct tracking (0) or the planner (60000), so only it is required to read back; the deceleration is still written and its floor tolerated. firmware_gain_mismatches compares the acceleration only, so a held X8 on direct tracking is not refused either. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… once, marked (default) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nstead of running off it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…edforward, wrist-IMU shake scores
- impedance_hz (240 | 480; tune.motion --impedance-hz): at 480 the MyActuator
impedance joints run every tick of a 480 Hz loop while the Damiao wrists stay
at 240 Hz on alternate ticks; the read lane (gripper, a4 position reads)
takes one tick in eight so a tick stays at 12 frames (14 on read ticks).
- Cogging ("osc") cancellation: JointConfig.cogging, a Fourier series in the
joint angle fitted from a friction sweep (almond_axol.tuning.cogging,
scripts/cogging_map.py --fit --save → calibration), sent to the core in the
motor frame on a second configure once offsets are resolved, evaluated at
the measured angle and added to the MIT t_ff on tracked ticks
(cogging_gain for A/B). Fixes the old table's sign (the torque the motor
supplied is the torque to add) and a detrend leak that inflated fits by 8%.
- 0x73 (protocol V4.4 position control with torque feedforward):
firmware.tf_rated_current_a turns an a4 joint's command into 0x73 carrying
gravity + inertia + cogging in 1%-of-rated-current units, faded in over 1 s,
on firmware that implements it (VersionDate >= 2026042402; the X6-P20
elbow stays on 0xA4 and the core logs why). tune.a4 --tf-probe estimates
the rated current from the iq step per percent.
- Wrist IMU: tune.motion / tune.a4 / tune.pid record each driven arm's wrist
ZED X One IMU in a light subprocess (almond_axol.zed.imu_worker) and score
the 3-15 Hz displacement at the gripper (1 s p2p mm, overall and vertical)
into metrics.imu and the run series; --no-imu opts out; the dashboard shows
it per run. Core trace gains cogging_ff / tf_pct columns (legacy CSVs still
compact). CONFIG_PROTO 15.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…k — per the SDK and the camera Checked against the robot's ZED SDK 5.4.1 (pyzed/sl.pyi) and the right wrist ZED X One GS: CameraOne.get_sensors_data(TIME_REFERENCE.CURRENT) returns the latest IMU sample without any grab(); the IMU delivers ~200 Hz (spec 400, effective_rate 202), m/s² and deg/s calibrated, UNIX-ns timestamps. A 1 ms poll phase-locked with the SDK and caught 128 of the 200 samples a second; 0.25 ms catches all of them for ~5% of a core. The grab() fallback (and its close-while-grabbing hazard) is gone; a camera silent for 1 s is reported. End to end on the box: 196.6 Hz, p99 gap 5.4 ms; a still camera scores 0.03 mm — the metric's floor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the jelly robot's wrist cameras (ZED X One GS, SDK 5.2.3) the first get_sensors_data after open() returned a sample ~36 minutes old. Samples more than 0.5 s older than the moment they are read are now skipped; the recording is then 199.6 Hz with a 5.9 ms worst gap, and a still camera scores 0.035 mm. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…0 Hz lane JointConfig.impedance_hz (240 | 480 | None = the config-wide rate) and tune.motion --fast-impedance SIDE.JOINT: e.g. shoulder_1 and the elbow commanded every tick of a 480 Hz loop while shoulder_2/3, wrist_1 and the wrists stay at exactly 240 Hz on alternate ticks. The core carries it as an optional joint field (CONFIG_PROTO 16); one fast_mit() rule drives both the rate check (any 480 Hz joint needs the 480 Hz loop) and the bus schedule. Dashboard: a '480 Hz impedance joints' field on the Recorded motion tab. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first jelly-robot baselines showed the impedance wobble the operator sees at 1-3 Hz (~2 mm in each of 1-2 and 2-3 Hz, vertical) against ~1 mm at 3-6 Hz and 0.3 mm above — the 3-15 Hz score left most of it out. The band now starts at 1 Hz (slow_osc's own motion is below it), the peak-to-peak window is 2 s, and low_mm / high_mm report the two sub-bands; dashboard and docs follow. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
For trying a gravity correction as a run before committing it to calibration (e.g. right.wrist_3.mass=0.86 — the ~0.11 kg the jelly robot's replay torques say the wrist carries beyond the model, likely the wrist camera and mount). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every impedance replay on the jelly robot's right arm pinned all five MyActuator motors' planner to 0 (the X8 shoulders, firmware 2026042403, keep a decel floor: 0 / 10 dps/s), because apply_firmware_gains wrote the config's planner_accel whatever the joint's wire mode. The planner shapes nothing the core sends an impedance joint, but the tuners' single-target 0xA4 moves (tune.friction / tune.breakaway homing) depend on it, and tune.breakaway's homing went wild (2026-09-24). The planner is now written, and checked on held joints, only where wire_mode is a4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The arms run impedance, where the firmware loops are inert; shoulder_1 and the elbow carried the tuned 0xA4 sets anyway, and every bring-up wrote them back. Both now carry the stock sets (X8: 0.008/0.1/0.03/1e-4, X6: 0.06/0.5/ 0.01/1e-4 — speed_ki read off the jelly robot's untouched left arm); the tuned sets stay defined for an --a4 run to override with. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Synthetic: -20..+40 deg legs at 3 then 6 deg/s with 0.5 s raised-cosine speed blends, every other joint held at slow_osc's start pose (inside the range slow_osc itself sweeps). The single-joint constant-speed test for shoulder_1's notchiness — a ~2 Hz stick-slip on its lightly damped impedance spring — through the production control path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Synthetic: -30..-90 deg legs at 6/3/3/6 deg/s with 0.5 s raised-cosine speed blends, every other joint held at slow_osc's start pose (inside the range slow_osc sweeps). The elbow's counterpart to s1_creep: with shoulder_1 at kp 450 / 480 Hz, the elbow carries as much of slow_osc's 1-3 Hz tip shake as shoulder_1 does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A 0xA4 / 0x73 joint more than 5 deg from the target it was just sent and moving away from it faster than 30 deg/s, on 3 replies in a row, is being driven there by its own loop (right shoulder_1 drove itself into its end stop on the jelly robot, 2026-09-23). The core goes limp as for a silent motor, the runaway joint braked at the MIT maximum kd 5 instead of coasting on the limp 0.25. Impedance joints keep no deviation check: on a compliant controller position error is not a safety signal. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
hold: 40 s at s1_creep's (slow_osc's) start pose - the wrist IMU's floor under the running controller, to split the shake a motion excites from what the controller sustains at rest (the camera reads 0.13 mm vertical with the arm off; s1_creep's ~0.6 mm is mostly a 1-1.75 Hz sway the joint encoders do not see). A pass where no joint moves 1 deg is no longer dropped: it keeps the per-joint buzz columns and the IMU score, and leaves the tracking summary keys out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>


Problem
Slow teleop moves on the right arm stair-step at ~2 Hz, felt as jumps at the hand. Traces from the
slow_oscrecording and a new static-friction probe put it on the RMD-X8-P20 shoulder gear trains:Velocity-weakening friction with a slope near −7 Nm·s/rad makes a slow slide unstable under any feedforward: the joint accelerates as friction drops, overshoots, stops, re-sticks. Friction also scales with transmitted torque (~10 % of gravity load), which is why the stairs only show up with the arm extended.
What this adds (all opt-in; the production law is bit-identical at the defaults)
stiction_gain,stiction_load_gain,stiction_err_deg): an error-sign Coulomb push ofgain·fc + load_gain·|gravity|, fading on measured velocity so it never drives the slip. On the same replay it cut right shoulder_1's stair height 4× (0.58° → 0.14°) and halved shoulder_2's error, with no hunting at rest.dither_nm,dither_hz), golden-angle staggered. 2 Nm took 40 % off the 1–4 Hz band without removing the cycle.wire_mode: a4: the core commands a MyActuator joint with the 0xA4 firmware position loop (speed-capped at the tracker limit) and pairs a 0x92 read so position stays 0.01°. No compliance, no host feedforward, torque telemetry NaN on that joint (watchdog blind there; logged at enable). Passthrough, limp and bring-up stay MIT. Stock firmware gains gave ~600 ms lag and the same 2 Hz cycle; 3×speed_kpvibrated immediately.stiction_ff/dither_ff; every new Rust function is golden-pinned to its Python original.Tools
axol tune.breakaway: static-friction probe (kp = 0 hold on a trimmed gravity feedforward, escalating torque ramps, both directions; reports F_static, F_static/fc, predicted stair, stiction_gain ceiling).axol tune.a4: sine or constant-speed triangle over 0xA4 with per-run firmware gains (RAM unless--persist, restored after), planner acceleration (0 = direct tracking, required to follow a stream), a buzz guard that restores gains on high-frequency motion or excess current, and creep-smoothness metrics (velocity ripple, stuck windows, lag, 1–4 Hz band, buzz). In the serve catalog and as a Firmware loop workbench tab.tune.motion --arms left|right,--a4 side.joint, and the new fields in--gain.scripts/creep_test.py,scripts/fw_gains.pyfor standalone probing.tuning/motions/slow_osc.npz: the recorded slow motion every A/B in this PR replayed.Verification
cargo test: 60 pass. Python: full suite passes apart from the pre-existing host-environment failures (test_can_setup,test_affinity,test_can_purge,test_snapshot_ring), identical on a clean checkout. ruff clean.slow_oscreplay): baseline, stiction 0.6, load-scaled stiction, dither 1/2 Nm, 0xA4 with stock and 3× gains.tsc -b— no Node on the robot. The new tab uses only existingWbFieldtypes; CI should confirm.Notes for reviewers
tune.a4 --accelhandles this per run and restores it; a joint left at 0 executes a stored target at the speed cap on wake, which the docs warn about.stiction_gain 0.25 / stiction_load_gain 0.18, shoulder_20.6 / 0.06. The X8's fc defaults from the reference robot over-state rest friction here; atune.friction --saveon the right shoulders is still owed.tune.a4finds no firmware-gain window that brings velocity ripple under ~0.3, the remaining fix is mechanical (a lower-stiction shoulder transmission or lubrication).🤖 Generated with Claude Code
Note
High Risk
Touches realtime motor commanding (protocol 6, 0xA4 path, feedforward math) and on-robot tuning that writes firmware gains/planner accel; misconfiguration or version skew between Python and
axol-rtcan affect motion safety.Overview
Adds stick-slip mitigation for geared shoulders: optional stiction compensation and torque dither on the MIT impedance path (Python
motion_controlandaxol-rtfeedforward), plus per-jointwire_mode: a4so MyActuator joints can run the firmware 0xA4 position loop from the core (paired 0x92 reads, NaN torque on those joints). Defaults keep prior behavior; calibration can persist the new fields. CONFIG_PROTO bumps 2 → 6 so joint config lines carry stiction, dither, and wire mode; traces addstiction_ff/dither_ff.New hardware tuning CLIs:
axol tune.breakaway(static breakaway vs slidingfc, stick-slip stair prediction) andaxol tune.a4(sine/triangle firmware-loop sweeps with gain restore and buzz guard).tune.motiongains--arms,--a4 SIDE.JOINT, and overrides for stiction/dither. Docs, serve catalog, and helper scripts (creep_test.py,fw_gains.py) follow.Reviewed by Cursor Bugbot for commit 6622d12. Configure here.