Skip to content

feat: tuned servo PID gains, and uncapped teleop setpoints - #46

Merged
Nick Hehr (HipsterBrown) merged 14 commits into
mainfrom
feat/pid-tuning-followups
Sep 3, 2026
Merged

Nick Hehr (HipsterBrown) merged 14 commits into
mainfrom
feat/pid-tuning-followups

Conversation

@HipsterBrown

@HipsterBrown Nick Hehr (HipsterBrown) commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Acts on a pidtune characterization of the SO-101's servos (2026-09-02, 6-DoF arm, STS3215, 12.2–12.3 V), plus two findings from testing this branch on the arm.

Changes

1. The module writes tuned per-joint PID gains at servo init. It never wrote them before — servos ran on whatever was in EEPROM.

id joint P D I
1 shoulder pan 32 48 0
2 shoulder lift 48 64 2
3 elbow 48 64 4
4 wrist flex 48 64 2
5 wrist roll 48 48 0
6 gripper never written — uncharacterized

Stock is 32/32/0. Steady-state error on the gravity-loaded joints drops ~4× (0.90° → 0.22° on joint 2); at stock P=32 the elbow could not complete a 100-count move against gravity inside 1.5 s, and does it in 130 ms at P=48. Nothing exceeds P=48 — P=64 oscillated at every D tested.

Writes are read-first and skipped when already correct, because EEPROM endurance is finite and this path runs on every Reconfigure and up to 3× per bring-up. Order is D → I → P and a failed write is terminal for that servo: a non-zero I is only stable against a raised D (joint 2 oscillated at D=32,I=4, stable at D=48,I=4), so damping-first keeps every reachable partial state at least as stable as stock.

2. Manual-mode compliance zeroes i_gain, not just p_gain. The integral path is independent of P, so hand-guiding accumulated standing error while a human held the arm off-target, then pushed back on release. Inert until change 1 gave the joints a non-zero I — the two must land together.

3. Teleop streams setpoints with no speed cap, no acceleration ramp, and no coordination read ("streamed": true in MoveToJointPositions's extra). A streamed caller shapes the trajectory with its own update rate, so any profile the servo applies between setpoints is pure lag, and coordinated arrival is meaningless for a goal superseded in ~10 ms.

lag rms err
acc=0 40 ms 25.5
acc=64 90 ms 61.4
acc=32 160 ms 103.2

Shape error stays flat at 6–7 throughout — the follower traced the right path, just late. The speed half came from testing this branch: raising speed_degs_per_sec kept improving tracking, so the cap was binding once the ramp stopped dominating.

4. Teleop default rate 20 Hz → 100 Hz. Command rate dominates smoothness more than any gain: 30 → 100 Hz cut chatter ~3.5× and tracking error ~28%, and at 30 Hz lag was pinned at 67 ms for every gain set, floored by the command period. The loop's ticker drops ticks, so a bus that can't sustain 100 Hz degrades rather than backing up.

Because 3 removes the bound that made a large initial gap safe, the mirror stays on profiled moves until the follower is within 5° of the leader, then latches. Guarding only the first cycle would not work — a servo goal write supersedes the previous one, so the next cycle 10 ms later would overwrite it while the arm was still far away. Exposed as streaming in the status DoCommand.

Reviewer notes

  • New behavior on existing installs. Upgrading rewrites servo EEPROM on first init. disable_servo_gains: true opts out; pidtune apply is the path for a differently-tuned arm.
  • Peak current rises steeply with D — 388 units at D=16 vs 691 at D=48 on a 100-count step. Not thermal in the study session (37–43 °C, +1–2 °C), but it would matter under sustained high-duty motion.
  • API surface: new disable_servo_gains arm attribute, new streamed key in MoveToJointPositions's extra, new streaming field in teleop status, changed teleop rate_hz default.
  • Unchanged on purpose: DefaultAccelDegsPerSecSq already maps to Acc 49, inside the measured optimum. MinExecutableSpeedDegsPerSec, DefaultLookaheadDeg and maxAccUnits are all now measured against stale assumptions; flagged in place, not changed.

Testing

go test ./..., gofmt -s -l ., go vet ./..., make bin/arm clean.

hardware-verified. Before merge:

  1. Gains write once on bring-up; a Reconfigure writes nothing.
  2. Arm holds under gravity; no oscillation on joints 2 and 3 (the study found joint 2 conditionally stable at I=4; it ships at I=2).
  3. Manual mode: hold a joint off-target ~10 s, release — no push-back.
  4. Teleop at 100 Hz, all 6 joints. The ticker drops ticks silently, so compare the status cycle count against elapsed time rather than trusting the absence of errors.
  5. Start teleop with the arms apart — follower travels at the configured speed, then streaming flips true.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
…guard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
writeGainIfChanged returned early on a read error, so a single transient
read on manual-mode exit left that joint at p_gain 8 / i_gain 0 in EEPROM
-- soft, across power cycles -- with only a log line. restoreCompliance
records the error and continues, and its only consumer logs a warning.

The asymmetry is the point: a failed apply leaves the arm stiff, which is
safe; a failed restore leaves it drooping, which is not. A read that fails
cannot prove the write is unnecessary, so it now falls through to the
write. The wear guard is unaffected in practice -- read failures are rare.

Retargets the Task 4 failure test from a read failure to a write failure,
which is now the only way to exercise warn-and-continue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
Command rate dominates teleop smoothness far more than gain tuning:
30->100 Hz cut chatter ~3.5x and tracking error ~28%, and at 30 Hz lag was
pinned at 67 ms for every gain set, floored by the command period. The
shipped 20 Hz default was below the lowest rate measured.

Lands after the acceleration bypass on purpose -- those figures were
measured at acc=0, and the shipped Acc 49 is the ~90 ms-lag row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
The D->I->P write order was justified as a safety property -- every prefix
at least as stable as the state before it -- but the loop logged and
continued, so the reachable states were all eight subsets rather than the
four prefixes. {I,P} without D leaves servo 3 at 48/32/4, the exact
combination the ordering exists to prevent: measured to oscillate, in
EEPROM, on a gravity-loaded joint, with no retry to clear it
(applyServoGains returns nothing, so initializeServosWithRetry never
re-runs for a gain failure).

01d57ea widened the window by attempting all three writes even when the
reads fail, so the break is now the only thing bounding the failure set.
Per servo, not per call.

The order had no test: reversing the slice left the whole suite green.
Adds a wire-level recorder, since final register values are
order-independent. Gains fields become byte so an out-of-range retune is a
compile error rather than silent truncation to less damping, and the
invariant test pins the report's caveat that servo 2 oscillated at I=4 in
2 of 3 repeat passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
An unset register reads back as zero, so asserting Acc == 0 could not tell
"commanded unlimited" from "never wrote acceleration at all" -- replacing
the branch body with `return nil` left the test green. Seeds a non-zero
acceleration first.

Nothing pinned the skipped coordination read either, which is half the
justification for the branch: reinstating the read left the whole suite
green. Adds FakeTransport.PacketCount and asserts one packet on the
unlimited path against two on the coordinated one, which also confirms the
bus-traffic claim rather than just asserting it in a comment. The count is
snapshotted before the assertion loop, whose own reads are packets too.

Also drops the stale claim that teleop depends on the read-failure
fallback -- it no longer reaches that read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
The tuned gains change the ground these were measured against, and two of
the comments still name p_gain 32 as though it were what ships.

MinExecutableSpeedDegsPerSec and DefaultLookaheadDeg were both sized
against the p_gain 32 droop band, which the new gains cut ~4x. Both are now
wider than they need to be -- an opportunity, not a regression, and neither
is re-derived, so the notes say measure before shrinking.

maxAccUnits records the open conflict: this module's calibration found the
Acc register saturating in the low 50s, while the 2026-09-02 step response
shows overshoot and peak current still climbing at 64/96/128. Likely the
two measured different things (arrival timing vs deceleration shape).

Also records that teleop's speed cap is deliberately not bypassed with the
acceleration ramp, and may bind first at 100 Hz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
Found on hardware: raising speed_degs_per_sec kept improving teleop
tracking, so the cap was binding once the acceleration ramp stopped
dominating -- the open question left at the bottom of docs/arm.md. A
streamed caller shapes the trajectory with its own update rate, so any
profile the servo applies between setpoints is pure lag, speed included.

Renames the extra key from unlimited_accel to streamed. It now controls
speed, acceleration and the coordination read, so a name describing one
register was already wrong, and the key is unreleased. minSpeedSteps and
MinAccUnits both stay 1: the profiled path still cannot reach either
sentinel, and a low configured value is not a substitute.

The fake transport now stores every register inside a sync-written range
rather than only the first, so the speed assertion can read goal_velocity
back. Without that it would have passed vacuously against an unset
register, the same way the acceleration one did.

This removes a bound. Teleop does not gate its first cycle on the follower
being near the leader, so a large initial gap is now closed at full servo
speed with no ramp. Documented at both call sites; not gated in code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
Uncapping speed and acceleration removed the bound that made a large
initial position error safe: with neither, the follower closes the gap as
fast as the servo physically can, and nothing stopped teleop handing a
streamed setpoint two arms in completely different poses.

readyToStream keeps the mirror on profiled moves until the follower is
within 5 deg of the leader on every joint, then latches. The latch is the
point -- profiling only the first cycle would do nothing, because a servo
goal write supersedes the previous one: the next cycle 10ms later would
overwrite the profiled goal with an unbounded one while the arm was still
far away. It must not re-evaluate after tripping either, since a running
mirror legitimately trails the leader by more than the gate.

Costs one extra follower read per cycle, only until it trips. Reported as
`streaming` in the status DoCommand, and cleared on start so a second
session does not inherit the first one's catch-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
Collapses controllerManualIO's three parallel per-servo maps into one map of
a struct with one restore loop, factors the twice-duplicated
read-one-byte-gain-and-check-width block into readGainByte, and cuts two
comment blocks that restated docs/arm.md and CLAUDE.md verbatim -- the docs
commit was meant to make those the single home.

The map collapse trades a structural invariant for a boolean: with three
maps, restoring a register the apply never captured was impossible; with
one, it depends on savedGains/savedTorque. Deleting that guard left the
whole suite green while writing p_gain 0 -- no position control -- to every
servo of a user who opted out of gain writes. TestApplyComplianceHonours-
ThePGainOptOut now restores as well as applies, and fails on that mutation.

Not applied: replacing writeGainIfChanged in applyCompliance with a compare
against the captured value. It saves two bus reads per servo but costs four
lines, and the reads are not what needed cutting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
The measurements belong in docs/arm.md and the invariants in CLAUDE.md;
several code comments had grown into a third copy of both. Trims those to
the claim plus a pointer, and tightens the arm.md and teleop.md prose to
the same.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHef7PBha9FHmWoCSduR8J
@HipsterBrown
Nick Hehr (HipsterBrown) merged commit 7a33fae into main Sep 3, 2026
2 checks passed
@HipsterBrown
Nick Hehr (HipsterBrown) deleted the feat/pid-tuning-followups branch September 3, 2026 21:12
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