Repository navigation
feat: tuned servo PID gains, and uncapped teleop setpoints - #46
Merged
Merged
Conversation
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Acts on a
pidtunecharacterization 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.
Stock is
32/32/0. Steady-state error on the gravity-loaded joints drops ~4× (0.90° → 0.22° on joint 2); at stockP=32the elbow could not complete a 100-count move against gravity inside 1.5 s, and does it in 130 ms atP=48. Nothing exceedsP=48—P=64oscillated at everyDtested.Writes are read-first and skipped when already correct, because EEPROM endurance is finite and this path runs on every
Reconfigureand up to 3× per bring-up. Order is D → I → P and a failed write is terminal for that servo: a non-zeroIis only stable against a raisedD(joint 2 oscillated atD=32,I=4, stable atD=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 justp_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-zeroI— the two must land together.3. Teleop streams setpoints with no speed cap, no acceleration ramp, and no coordination read (
"streamed": trueinMoveToJointPositions'sextra). 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.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_seckept 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
streamingin thestatusDoCommand.Reviewer notes
disable_servo_gains: trueopts out;pidtune applyis the path for a differently-tuned arm.D=16vs 691 atD=48on 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.disable_servo_gainsarm attribute, newstreamedkey inMoveToJointPositions'sextra, newstreamingfield in teleopstatus, changed teleoprate_hzdefault.DefaultAccelDegsPerSecSqalready maps to Acc 49, inside the measured optimum.MinExecutableSpeedDegsPerSec,DefaultLookaheadDegandmaxAccUnitsare all now measured against stale assumptions; flagged in place, not changed.Testing
go test ./...,gofmt -s -l .,go vet ./...,make bin/armclean.hardware-verified. Before merge:
Reconfigurewrites nothing.statuscycle count against elapsed time rather than trusting the absence of errors.streamingflips true.🤖 Generated with Claude Code