Skip to content

Stiction compensation, 0xA4 wire mode, and firmware-loop tuning for the X8 shoulders - #317

Open
shawnpatel wants to merge 81 commits into
mainfrom
smooth-slow
Open

shawnpatel wants to merge 81 commits into
mainfrom
smooth-slow

Conversation

@shawnpatel

@shawnpatel shawnpatel commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Problem

Slow teleop moves on the right arm stair-step at ~2 Hz, felt as jumps at the hand. Traces from the slow_osc recording and a new static-friction probe put it on the RMD-X8-P20 shoulder gear trains:

shoulder_1, gravity load > 9 Nm friction
static (breakaway) 2.4–3.3 Nm
sliding at 0.04–0.08 rad/s 2.1 Nm
sliding at 0.08–0.15 rad/s 1.4 Nm
sliding at 0.15–0.4 rad/s 0.7 Nm
at the rest pose, any speed ~0.65 Nm (below the configured fc of 1.30)

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 compensation per joint (stiction_gain, stiction_load_gain, stiction_err_deg): an error-sign Coulomb push of gain·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.
  • Torque dither per joint (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_kp vibrated immediately.
  • Config protocol 2 → 6; trace gains 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.py for 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.
  • Hardware A/Bs on the right arm (slow_osc replay): baseline, stiction 0.6, load-scaled stiction, dither 1/2 Nm, 0xA4 with stock and 3× gains.
  • Not run: web lint / tsc -b — no Node on the robot. The new tab uses only existing WbField types; CI should confirm.

Notes for reviewers

  • Two motors' planner acceleration were changed during testing (elbow restored to 5000; shoulder_1 still at 0 in ROM pending power). tune.a4 --accel handles 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.
  • Recommended production config for this robot, pending a teleop check at rest: right shoulder_1 stiction_gain 0.25 / stiction_load_gain 0.18, shoulder_2 0.6 / 0.06. The X8's fc defaults from the reference robot over-state rest friction here; a tune.friction --save on the right shoulders is still owed.
  • If tune.a4 finds 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-rt can affect motion safety.

Overview
Adds stick-slip mitigation for geared shoulders: optional stiction compensation and torque dither on the MIT impedance path (Python motion_control and axol-rt feedforward), plus per-joint wire_mode: a4 so 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 add stiction_ff / dither_ff.

New hardware tuning CLIs: axol tune.breakaway (static breakaway vs sliding fc, stick-slip stair prediction) and axol tune.a4 (sine/triangle firmware-loop sweeps with gain restore and buzz guard). tune.motion gains --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.

…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>
@vercel

vercel Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
axol Ready Ready Preview Sep 24, 2026 7:13pm UTC

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6622d12. Configure here.

Comment thread rust/axol-rt/src/serve.rs Outdated
pending -= 1;
a4_stage[slot] = (speed, iq);
}
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6622d12. Configure here.

Comment thread scripts/creep_test.py
got = await _write_plan_accel(driver, original_accel[0])
print(
f" position planner restored: accel {got[0]}, decel {got[1]} dps/s"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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>
shawnpatel and others added 9 commits September 23, 2026 23:41
…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>

This branch was successfully deployed

1 active deployment
Preview — 9eb7cd97 Deployed Sep 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant