feat: encoder linearity — evict the seam so a linear tick axis is finally true (#35)#40
Conversation
wrist_roll's encoder wraps and no wall was ever found across its entire measured [21, 4073] free range, so unlike elbow_flex it cannot be fixed by an encoder re-zero: a re-zero only relocates the seam, and a joint whose travel covers the whole circle has no arc to relocate it into. Instead, arm_spec now carries a SOFTWARE-only SoftLimit for wrist_roll, (100, 3995), that restricts its permitted travel to a genuine sub-turn arc and leaves a 200-tick dead arc straddling the 4095->0 seam. The servo's EEPROM min_angle/max_angle stay untouched at the factory 0-4095 — this module never imports the bus, so it has no way to write a register at all. dead_arc_contains_seam() is the enforceable predicate behind the guarantee: for any well-ordered (min_tick, max_tick) interval the excluded region is always [TICK_MIN, min_tick) u (max_tick, TICK_MAX], which is non-empty (and therefore contains the seam) iff min_tick > TICK_MIN or max_tick < TICK_MAX. _require_dead_arc_contains_seam() runs this against SOFT_LIMITS at import time, so a future edit that widens the range back to the full turn fails loudly at import rather than silently reintroducing the bug — verified manually and covered by test_widening_the_soft_limit_to_the_full_range_fails_validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
Read-only verification spike for the elbow_flex encoder re-zero (issue #35). No hardware was touched; no servo was written; no Python file was modified. The mechanism EXISTS and is sufficient: Ofs / Homing_Offset — addr 31, 2 bytes, EEPROM (persistent), sign-magnitude with sign bit 11 (NOT two's complement), range -2047..+2047, and the servo applies it to the position it REPORTS: Present_Position = Actual_Position - Homing_Offset. Triple-sourced: Feetech's own SMS_STS.h (addr 31 sits under the "EEPROM (Read and Write)" heading; SRAM does not start until addr 40), Feetech's official Python SDK, and LeRobot — which drives this exact arm. A second mechanism also exists: writing 128 to Torque_Enable (addr 40) runs the firmware's midpoint calibration (Feetech's CalibrationOfs). Range arithmetic for elbow_flex: travel is 2020 -> 4095 -> seam -> 0 -> ~126, i.e. >= 2202 ticks, leaving a 1894-tick unreachable arc spanning raw (126, 2020). Evicting the seam into it needs H in that window; the midpoint H = 1073 is well inside the +/-2047 limit (974 ticks of head-room) and maps the travel to a monotonic [947, 3149]. Independently, LeRobot's half-turn rule gives H = 1074 — the same answer within one tick. CAVEAT (why not a clean GO): it is undocumented whether the corrected Present_Position is reduced modulo 4096 — so the seam RELOCATES to raw == Ofs — or reported as a plain signed subtraction, which would leave the discontinuity pinned at raw 4095->0 and make the re-zero useless. Present_Position really is sign-magnitude on bit 15, so negatives are representable. All evidence points to the modular reading (LeRobot's shipped SO-101 calibration depends on it), but it is circumstantial. The doc specifies the exact hardware test, including the PR #21-style power-cycle check for EEPROM persistence and a torque-off manual sweep that settles the wrap semantics outright. Also could not determine: whether Ofs applies to Goal_Position as well as Present_Position, and elbow_flex's far wall (never measured — explore cannot see across the seam), which makes the 2202-tick travel a lower bound.
…ow unreachable Task t9 added SOFT_LIMITS, giving wrist_roll a soft travel limit of (100, 3995) whose dead arc contains the 4095->0 encoder seam. Nothing consumed it. Every move resolved its bounds from the servo's EEPROM angle-limit registers, which are the factory 0-4095 on every joint of this arm — so `arm flex wrist_roll --to 4090 --apply` drove the joint straight into the dead arc and across the seam. A soft limit nobody reads is inert data, and the spec's claim that no joint's usable travel crosses the wrap was false. Add arm_spec.resolve_bounds(joint, eeprom_min, eeprom_max), a pure read-side resolver returning the INTERSECTION of the servo's EEPROM range with the joint's soft limit — each end independently takes the tighter of the two, so a genuinely narrower EEPROM bound still wins and a joint with no soft limit is returned verbatim. Route every EEPROM-to-move-bounds site through it: - arm flex (arm.py _resolve_joint_bounds, both compliant and gentle) - arm explore's grid (arm.py _build_grid_spec) — the engine takes every move bound from GridSpec.bounds, so soft-limiting the grid covers the flood-fill and the escape probes - demo_sweep (demo.py _bounds_for) The runtime package now has exactly two reads of min_angle/max_angle and both go through the resolver; every other bounds consumer receives them as parameters. The soft limit is never written to a servo: arm_spec imports no bus, and a test asserts no motion path touches EEPROM addresses 9/11. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
… EEPROM Lock dance) Adds the read/write primitives for the STS3215 Ofs / Homing_Offset register (EEPROM addr 31, 2 bytes) — the mechanism issue #35 needs to re-zero elbow_flex so its 4095->0 encoder seam falls in the arc the joint cannot reach, making the linear-axis assumption true rather than assumed. Implements docs/spikes/sts3215-offset-register.md. - encode_offset / decode_offset: pure module-level codec for the register's SIGN-MAGNITUDE encoding (sign on bit 11 — NOT two's complement). Round-trip tested across the whole +/-2047 range; |offset| > 2047 raises CliError(EXIT_USER_ERROR) rather than truncating the magnitude into the sign bit (LeRobot #3193 is a real SO-101 hitting exactly this). - FeetechBus.read_offset / write_offset. write_offset emits exactly (40,0) torque-off -> (55,0) unlock -> (31,wire) -> (55,1) re-lock, validates BEFORE any wire traffic, and best-effort re-locks on every failure path so a failed write never strands the motor at Lock=0 (PR #21). It writes addr 31 and nothing else — NOT the position limits at 9/11 that LeRobot's write_calibration also writes, which in servo mode would clamp goals and narrow the reachable set the re-zero exists to recover. Pinned by an exact register allow-list test. - FakeBus models the register, the Lock dance, and its effect on what the servo reports (Present = Actual - Ofs) through one funnel shared by read_position and read_info, so the two views of addr 56 can never disagree. The modulo-4096 reduction — the spike's one unproven assumption — is a switch (offset_wraps), not a hard-coded belief. ServoModelBus converts goals back to the raw frame so goals and feedback never live in different frames. - arm read surfaces the offset read-only (text column + --json field), signed. Baseline 960 tests -> 1028 (68 new). No new runtime deps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
… EEPROM Lock dance)
elbow_flex's encoder wraps INSIDE its physical travel (issue #35): driven far enough it crosses the raw 4095->0 seam and reads back near zero, so its reported position is not monotonic with joint angle and every position comparison in the codebase is silently wrong for it. Shift the servo's encoder zero (Ofs / Homing_Offset, EEPROM addr 31) so the seam falls in the 1894-tick arc the joint physically cannot reach, and the linear-axis assumption becomes TRUE rather than merely assumed. `arm rezero <joint>` — the gated write: - Three-mode consent (dry-run / TTY prompt / --apply). The dry-run opens NO bus and prints the exact register writes. - COMMANDS NO MOTION, on any path. This is the load-bearing safety property: elbow_flex rests at raw ~126, PAST its wrap, so a linear goal rotates it the long way round through its whole travel and into a wall. The tool that makes the axis linear cannot rely on the axis being linear. It reads where the joint physically is, derives the offset from the joint's measured unreachable arc, and writes it. Pinned at the register level by tests. - Clears a latched overload first (write_offset's own torque-off would otherwise raise on a joint that just fought a wall — which is how the arc was measured). - Torque goes off for the write and STAYS off; the servo must not be holding while its frame of reference changes underneath it. - Refuses wrist_roll WITH THE REASON, and it is a different reason from the other four: a re-zero only RELOCATES a seam, it can never EVICT one from a joint whose travel covers the whole circle. It has a soft limit instead. The other four simply do not wrap. - Per-joint arcs live in arm_spec (REZERO_ARCS), mirroring SOFT_LIMITS. The offset is DERIVED from the arc (midpoint 1073 for elbow_flex), never typed, so correcting the arc corrects the offset. Import-time guards reject an arc with no interior or an unrepresentable offset (raw 2048). `arm rezero <joint> --verify` — the seam-eviction proof: Reading the offset back proves it was APPLIED. It does NOT prove the seam MOVED. Only a sweep does. Torque off, the human hand-moves the joint through its entire travel, and the verb asserts no discontinuity anywhere — a human arm being the only actuator available that does not need a linear tick axis to work. Four verdicts, because "did not fail" is not "proved it works": seam-evicted / seam-not-evicted (STOP, exit 2) / seam-present-baseline / inconclusive. A short clean sweep is INCONCLUSIVE, never a pass — claiming a pass from a sweep that never went near the seam would close the open question with a lie. The undocumented caveat (spike §4) is modelled, not papered over: FakeBus offers both offset_wraps=True (seam relocates, fix works) and offset_wraps=False (plain signed subtraction, seam stays pinned, re-zero does NOTHING), and every sweep claim is asserted in BOTH worlds. Under offset_wraps=False --verify FAILS loudly with exit 2 — that failure is the feature. The discontinuity threshold is 500 ticks, not the tempting 2048: a masked signed subtraction shows up as only a ~1949-tick jump, and 2048 would call it a pass. Also: docs/hardware-rezero-procedure.md — the hand-run procedure for the follower (/dev/ttyACM1, elbow_flex = motor id 3), including the POWER-CYCLE (PR #21 exists because an EEPROM write looked fine until power-cycled) and the --verify sweep. 106 new tests (1028 -> 1134). rezero.py 99% / arm_spec.py 100% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
…ally TRUE (v0.21.0) Refs #35. Plan tasks t5, t7, t8, t9, t9b. elbow_flex and wrist_roll cross the 4095->0 encoder seam WITHIN their physical travel, so their encoder value is not monotonic with joint angle. Everything in the codebase that reasons about position — arrival checks, clamp_goal, ReachMap's (min,max) ranges — assumes a linear tick axis, and for these two joints that assumption is FALSE. For elbow_flex the sorted endpoints of its measured range describe exactly the UNREACHABLE region: the map is not imprecise, it is INVERTED. THE FINDING THAT SPLIT THE FIX IN TWO. Issue #35 recommended re-zeroing both joints. That is impossible for wrist_roll, even in principle: it was mapped free through its entire range (it stopped only where the 0-4095 clamp stopped it, not at a wall), so its travel covers the WHOLE CIRCLE — and no choice of zero moves a seam out of a travel that includes every angle. Re-zero only RELOCATES a seam; it cannot EVICT one. So: elbow_flex -> RE-ZEROED. It has real walls, so the seam parks in its unreachable arc. wrist_roll -> SOFT-LIMITED to (100, 3995), a software bound whose dead arc CONTAINS the seam. Never an EEPROM angle-limit write. General rule now in the code: ask whether a joint's travel is BOUNDED or CONTINUOUS. A re-zero is only available to a bounded joint. t5 (spike, docs/spikes/) — the offset register, triple-sourced: Ofs at addr 31, 2 bytes, SIGN-MAGNITUDE on bit 11 (not two's complement), +/-2047, EEPROM. elbow_flex needs H=1073, with 974 ticks of head-room. VERDICT: GO-WITH-CAVEAT — it is UNDOCUMENTED whether the servo reduces the corrected position mod 4096 (seam moves, fix works) or does a plain signed subtraction (seam stays pinned, re-zero achieves NOTHING). We did not assume: FakeBus models BOTH via offset_wraps. t7 — the primitives. write_offset's entire wire surface is torque-off -> unlock -> addr 31 -> re-lock, pinned by an exact allow-list so addrs 9/11 (the position limits) can never be touched: LeRobot's write_calibration writes them, and in servo mode they CLAMP goals — they would narrow the very reachable set we are recovering. t8 — `arm rezero`, and its --verify seam-eviction proof. The bootstrap problem is the hard part: elbow_flex RESTS past its wrap at ~126, so the natural procedure ("drive to mid-travel, then centre") is exactly the one that must not run — a goal of 3121 looks like a modest move and is in fact a rotation the LONG WAY ROUND, into a wall. So the re-zero commands NO motion at all: it reads where the joint is, derives the offset from the joint's measured unreachable arc, and writes it. --verify is the step that settles the caveat, and reading the offset back does NOT substitute for it: that proves the offset was APPLIED, not that the seam MOVED. Only a sweep proves that. Two traps it avoids: * The discontinuity threshold is 500, NOT the tempting 2048. If the firmware does a plain signed subtraction AND read_position's & 0x0FFF folds the negative back into range, the seam jump is only ~1949 ticks — a 2048 threshold would call that a PASS and tell the operator a broken fix works. * A short clean sweep is INCONCLUSIVE, never a pass. Covering 200 of 2202 ticks and seeing no seam proves nothing; >=80% travel coverage is required before "no discontinuity" is allowed to mean anything. t9/t9b — the soft limit, and the enforcement it needs to be more than inert data. arm_spec.resolve_bounds() INTERSECTS each joint's EEPROM limits with its soft limit (intersection, not replacement — a genuinely narrower EEPROM bound still wins), and every site that resolved move bounds from EEPROM now routes through it. 1134 tests pass (was 911 on main). teken rubric passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
|
/agentic_review |
PR Summary by QodoEvict encoder seam: elbow_flex rezero + wrist_roll soft-limit enforcement
AI Description
Diagram
High-Level Assessment
Files changed (21)
|
…atenated f-string Two SonarCloud findings on #40, both fair. S1192 — '(auto-detect at apply)' written out three times. adding 'arm rezero' as a third gated motion verb pushed it over the threshold. Hoisted to _PORT_UNRESOLVED alongside the existing verb-label constants. (The same literal was hoisted on the speed-profile branch; this branch predates that, so the two will converge on merge.) S5799 — two adjacent f-strings inside a LIST literal, relying on implicit concatenation. It was intentional line-wrapping, but Sonar is right that this is exactly the shape a forgotten comma takes, and in a list of report lines a missing comma silently merges two rows rather than failing. Merged into one f-string. 1134 tests still pass; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
Code Review by Qodo
1.
|
…oad before planning plan_rezero() is reads-only, but FeetechBus._read_register raises OverloadError off the status byte regardless of read vs. write, so a motor latched in overload aborted arm rezero before it ever reached apply_rezero (the only call site that clears the latch). elbow_flex's arc is measured by driving the joint into a wall — exactly how a Feetech servo latches an overload — so an operator running the documented rezero procedure right after that measurement hit this every time. _run_rezero_write() now catches OverloadError around the plan_rezero() call, clears the latch, and retries exactly once; a second failure propagates rather than looping. The recovery only fires inside the except branch, never ahead of it, so the already_applied no-op path and any healthy planning read never call clear_overload and never silently de-energise a holding joint. The operator is told on stderr that the latch was cleared and the joint went limp.
Both branches added a gated verb to the same 1700-line arm.py — `arm profile` on main, `arm rezero` here — and git's conflict regions did not align on function boundaries, so a naive union spliced one verb's output list into the middle of the other's function call. Resolved by taking main's arm.py whole (profile intact) and replaying the encoder branch's delta onto it, then checking mechanically that (merged - main) == (encoder - base) apart from the three verb ENUMERATIONS, which a union gets wrong by construction and which must name BOTH verbs: - the module docstring's first line - the "verbs": [...] JSON list - the "Verbs: ..." text line Also carried the rest of the encoder delta that rides alongside rezero: `arm read`'s signed `offset` column/field, and `_resolve_joint_bounds` (the soft-limit intersection) at its two call sites. 1202 tests pass (main 974 + encoder 1139, no file dropped). teken cli doctor --strict: 26/26. Both verbs resolve through explain/overview/learn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
|
FIX — verified against the source, and it is worse than an edge case: this would have fired on the documented procedure, every time. Confirmed the chain. What makes it the expected state rather than a corner: Fixed in One thing worth stating explicitly, because it is the trap in this fix: the recovery is conditional, and must be. Five new tests, including the one that matters: a motor latched on its very first bus op is still re-zeroed successfully.
|
…D022) My CHANGELOG conflict resolution joined the 0.21.0 and 0.20.1 sections without a blank line. Also records the latched-motor rezero fix, which the merge commit carried but the changelog had not yet named. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6
|



Refs #35. Plan tasks t5, t7, t8, t9, t9b.
The bug
elbow_flexandwrist_rollcross the 4095→0 encoder seam within their physical travel, sotheir encoder value is not monotonic with joint angle. Everything that reasons about position —
arrival checks,
clamp_goal,ReachMap's(min,max)ranges — assumes a linear tick axis, and forthese two joints that assumption is false. For
elbow_flexthe sorted endpoints of its measuredrange describe exactly the unreachable region: the map is not imprecise, it is inverted.
The finding that split the fix in two
Issue #35 recommended re-zeroing both joints. That turns out to be impossible for
wrist_roll,even in principle — and the issue's own evidence proves it.
wrist_rollwas mapped free through itsentire range: it stopped only where the 0–4095 clamp stopped it, never at a wall. So its travel
covers the whole circle — and no choice of zero moves a seam out of a travel that includes
every angle. Re-zero only relocates a seam; it cannot evict one.
elbow_flexwrist_roll(100, 3995)— a dead arc that contains the seamThe general rule now lives in the code: ask whether a joint's travel is bounded or continuous.
A re-zero is only available to a bounded joint.
wrist_roll's soft limit is a software bound —never an EEPROM angle-limit write.
t5 — the spike (
docs/spikes/sts3215-offset-register.md)The register, triple-sourced against Feetech's SDK, Feetech's Python lib, and LeRobot:
Ofs, addr31, 2 bytes, sign-magnitude on bit 11 (not two's complement), range ±2047, EEPROM.
elbow_flexneedsH = 1073, with 974 ticks of head-room.Verdict: GO-WITH-CAVEAT. It is undocumented whether the servo reduces the corrected position
mod 4096 (seam moves → the fix works) or does a plain signed subtraction (seam stays pinned →
the re-zero achieves nothing). Evidence favours the former but is circumstantial.
We did not assume.
FakeBusmodels both readings viaoffset_wraps, and the verb is testedagainst each. If hardware settles it the wrong way, the pessimistic behaviour is already built.
t7 — the primitives
write_offset's entire wire surface istorque-off → unlock → addr 31 → re-lock, pinned by anexact allow-list so addrs 9/11 can never be touched. That guard is deliberate: LeRobot's
write_calibrationdoes write them, and in servo modeMin/Max_Position_Limitclamp goals —they would narrow the very reachable set we are trying to recover.
t8 —
arm rezero, and the proofThe bootstrap problem is the hard part.
elbow_flexcurrently rests past its wrap at ~126, sothe natural procedure — "drive to mid-travel, then centre" — is exactly the one that must not run: a
goal of 3121 looks like a modest tick-space move and is in fact a rotation the long way round,
through the entire unreachable arc, into a wall. So the re-zero commands no motion at all: it
reads where the joint is, derives the offset from its measured unreachable arc, and writes it.
Pinned at the register level —
position_writes == [], nothing at addr 42.--verifyis the step that settles the caveat, and reading the offset back is not a substitutefor it. That proves the offset was applied; only a sweep proves the seam moved. Torque off, the
operator hand-moves the joint through its travel, and the verb asserts monotonicity.
Two traps it avoids, either of which would have produced a false pass:
subtraction and
read_position's& 0x0FFFfolds the negative back into range, the seam jump isonly ~1949 ticks — a 2048 threshold would call that a pass and tell the operator a broken fix
works.
inconclusive, never a pass. Covering 200 of 2202 ticks and seeing noseam proves nothing. ≥80% travel coverage is required before "no discontinuity" is allowed to mean
anything.
Under
offset_wraps=Falsethe verify fails loudly, exit 2, verdictseam-not-evicted, and says"this is not a retry — take it back for a re-decision."
t9 / t9b — the soft limit, and making it bind
t9 added
wrist_roll's soft limit; a review caught that nothing consumed it — every move stillresolved bounds from the servo's EEPROM (factory 0–4095), so
arm flex wrist_roll --to 4090wouldhave driven straight into the dead arc. t9b closes it:
arm_spec.resolve_bounds()intersects theEEPROM limits with the soft limit (intersection, not replacement — a genuinely narrower EEPROM bound
still wins), and every site that resolved bounds from EEPROM now routes through it.
Hardware
docs/hardware-rezero-procedure.mdis the followable version — including the mandatorypower-cycle (PR #21 exists because an EEPROM write read back fine and reverted overnight) and the
hand-sweep. A passing sweep also measures
elbow_flex's far wall for the first time: nothingcould see across the seam before.
Gates
1134 tests pass (911 on main). black / isort / flake8 / bandit clean.
teken cli doctor --strictpasses. markdownlint clean.