Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eidetic/memory/arm101-cli__public.jsonl

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/). This project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.22.0] - 2026-07-12

### Added

- `docs/hardware-rezero-run-2026-07-12.md` — the hardware run-log. The re-zero is PROVEN on the follower: the STS3215 reduces the corrected position modulo 4096, so a homing offset genuinely RELOCATES the encoder seam. Same joint, same hand sweep, before/after: offset 0 gave monotonic=False with 1 discontinuity; offset 1073 gave monotonic=True with 0 discontinuities over 2196 ticks. It survived a power cycle. The t5 spike GO-WITH-CAVEAT is now closed by measurement.

### Changed

- `REZERO_ARCS` is derived from walls the ARM measured itself. `gentle_move` was driven past the known travel and let the load-watch find each wall by feel; both contacts saturated at the 500 torque cap. The arm out-measured the human on both sides (raw 251 vs 218, and 2061 vs 2107) because it presses to a fixed load every time instead of to whatever felt firm. The arc is INSET from those walls by a margin, so a harder push can never make the table contradict the arm.
- The rezero tests DERIVE every expectation from the arc table instead of copying it. ~125 arc-coupled literals were hard-coded across two files, so re-measuring a table that exists to be re-measured broke 34 tests. Changing a wall or the margin now leaves the suite green.
- Docstrings and the `explain` catalog no longer quote ticks at all — a document that names a measurement is a document that goes stale, and `explain arm rezero` is what prints to whoever is standing at the arm.

### Fixed

- `arm rezero`: the unreachable arc was in the WRONG FRAME. It held REPORTED ticks (read off a servo already carrying the factory offset) and used them as RAW. Every SO-101 ships with `Ofs = +85`, not the factory 0 the spike assumed — and that is precisely WHY `elbow_flex` wraps: the seam sits where `Actual == Ofs`, i.e. at raw 85, which is BELOW the unreachable arc and therefore INSIDE the joint travel. The old target landed inside the true arc anyway, with ~866 ticks of margin: it worked by luck, and every read-back looked correct.
- `arm rezero` refused outright on a servo holding the factory 85 — that is, on every fresh SO-101. Now that `raw = (reported + offset) mod 4096` is known, any readable frame converts, and the goal is stated as a PLACE (the seam is out of the travel) rather than a magic number: an arm already holding a seam-evicting offset is a clean no-op, not a rewrite.
- `bus`: a dropped packet raised a bare `IndexError` from inside the vendor SDK instead of a `CliError`, leaking a raw traceback — hit live on a healthy bus. Reads now convert SDK faults to `CliError` and retry (bounded); WRITES ARE NEVER RETRIED, because a failed write may in fact have landed (that ambiguity is exactly how #21 and #38 arose). `OverloadError` is never retried — it is a latched state, not a dropped packet.
- `bus`: a read issued immediately after an EEPROM write could return GARBAGE — a `read_position` 0.2s after `write_offset` returned 0 while the servo genuinely held 3387. A plausible-looking wrong value is far more dangerous than an error. EEPROM writes now settle before the next read.

## [0.21.0] - 2026-07-12

### Added
Expand Down
55 changes: 43 additions & 12 deletions arm101/cli/_commands/arm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1595,13 +1595,18 @@ def _emit_rezero_plan(
"wire_value": encode_offset(offset),
"register": f"addr {31} (Ofs/Homing_Offset, EEPROM, 2 bytes)",
"unreachable_arc": [arc.low, arc.high],
"unreachable_arc_frame": "raw encoder ticks (NOT the ticks a servo reports)",
"seam_moves_to_raw_tick": arc.midpoint,
"expected_travel_ticks": arc.travel_ticks,
"note": (
"COMMANDS NO MOTION. This is a persistent EEPROM write: it shifts the "
"joint's encoder zero so the 4095->0 seam falls inside the arc the "
"joint cannot reach. The joint is NOT moved — not before, not during, "
"not after."
"not after. The offset the servo already holds is READ at apply time "
"and converted (raw = reported + offset, mod 4096); a factory servo "
f"holds {arm_spec.FACTORY_ENCODER_OFFSET}, not 0. If the offset it "
"already holds ALREADY puts the seam inside the unreachable arc, the "
"seam is evicted, this verb writes NOTHING, and it says so."
),
"writes": _rezero_write_sequence(offset),
}
Expand Down Expand Up @@ -1679,7 +1684,9 @@ def _emit_rezero_write(
"power-up if the Lock register was mishandled; that is PR #21, and it is the only "
"way to know.",
f"2. Re-read the offset: 'arm101 arm read --json' — {plan.joint}'s 'offset' must "
f"still be {plan.target_offset}. If it reverted to 0, the write did not persist.",
f"still be {plan.target_offset}. If it reverted to "
f"{arm_spec.FACTORY_ENCODER_OFFSET} (the factory default) or to whatever it held "
f"before ({plan.current_offset}), the write did not persist.",
f"3. PROVE THE SEAM MOVED: 'arm101 arm rezero {plan.joint} --verify'. The read-back "
"above proves only that the offset was APPLIED. It does NOT prove the seam "
"RELOCATED — only a torque-off sweep of the joint's whole travel can.",
Expand Down Expand Up @@ -1707,10 +1714,13 @@ def _emit_rezero_write(
f"## arm rezero {plan.joint} ({role}) — encoder offset written on {port}",
"",
f"- motor : {plan.motor}",
f"- offset before : {plan.current_offset}",
f"- offset before : {plan.current_offset}"
f" (seam was at raw tick {plan.current_seam_tick} — inside this joint's travel)",
f"- offset written : {plan.target_offset:+d} (wire value "
f"{encode_offset(plan.target_offset)}, EEPROM addr 31)",
f"- offset read back : {read_back} <- the write LANDED",
f"- seam now at : raw tick {arm_spec.seam_tick(plan.target_offset)}"
" — inside the arc the joint cannot reach",
f"- raw position : {plan.raw_position} (unchanged — no motion was commanded)",
f"- reported before : {plan.reported_position}",
f"- reported after : {shift['observed_position']}"
Expand Down Expand Up @@ -1919,7 +1929,8 @@ def _run_rezero_write(
plan = rezero.plan_rezero(bus, motor, joint) # type: ignore[arg-type]

if plan.already_applied:
_emit_rezero_noop(role, port, plan, json_mode=json_mode)
_, arc = rezero.require_rezeroable(joint)
_emit_rezero_noop(role, port, plan, arc, json_mode=json_mode)
return

read_back = rezero.apply_rezero(bus, motor, plan.target_offset) # type: ignore[arg-type]
Expand Down Expand Up @@ -1950,17 +1961,28 @@ def _emit_rezero_noop(
role: str,
port: str,
plan: "rezero.RezeroPlan",
arc: "arm_spec.UnreachableArc",
*,
json_mode: bool,
) -> None:
"""Report that the servo already holds the target offset — and write nothing.
"""Report that the seam is ALREADY out of the joint's travel — and write nothing.

Idempotence matters here more than it usually does: the procedure this verb
belongs to tells the operator to power-cycle the arm and come back, so a
second run against an already-re-zeroed joint is the *expected* path, not a
mistake. Re-writing the same value would be harmless on the wire and
corrosive in the log — it would make "the offset was written" ambiguous about
which run wrote it.
mistake. Re-writing would be harmless on the wire and corrosive in the log —
it would make "the offset was written" ambiguous about which run wrote the
calibration actually in force.

The condition is **the seam is evicted**, not "the register holds the number
we would have written". Those come apart on the arm this was built for: the
follower carries ``1073`` from the first, frame-confused re-zero, its seam
sits at raw 1073 — deep inside the unreachable ``(207, 2107)`` — and a hand
sweep proved its travel continuous across all 2196 ticks. It is FIXED. A verb
that insisted on the arc's midpoint would burn an EEPROM write to slide a
seam from one tick the joint can never reach to another tick the joint can
never reach, and the operator would have nothing to show for it but a
finite-write part with one fewer write left.
"""
if json_mode:
emit_result(
Expand All @@ -1969,6 +1991,8 @@ def _emit_rezero_noop(
"role": role,
"port": port,
"plan": plan.as_dict(),
"unreachable_arc": [arc.low, arc.high],
"fresh_rezero_would_write": arc.offset,
"written": False,
"reason": "already-applied",
"seam_eviction_proven": False,
Expand All @@ -1982,13 +2006,20 @@ def _emit_rezero_noop(
f"## arm rezero {plan.joint} ({role}) — already re-zeroed on {port}",
"",
f"- motor : {plan.motor}",
f"- offset in force : {plan.current_offset} (this joint's computed offset)",
f"- offset in force : {plan.current_offset}",
f"- seam sits at : raw tick {plan.current_seam_tick} — strictly inside "
f"({arc.low}, {arc.high}), the arc this joint physically cannot reach",
f"- reported now : {plan.reported_position} (raw {plan.raw_position})",
"",
"Nothing written — the servo already holds this offset.",
"Nothing written — the seam is ALREADY out of this joint's travel, which is "
"the entire goal. A fresh re-zero would write "
f"{arc.offset} (the arc's midpoint, for maximum margin), but any offset "
"inside the arc does the job equally well: the joint cannot tell the "
"difference, and an EEPROM has a finite number of writes.",
"",
"That the offset is APPLIED does not mean the seam MOVED. If you have not "
f"proven it yet, run: arm101 arm rezero {plan.joint} --verify",
"That the seam is evicted IN THE TABLE'S ARITHMETIC does not mean it MOVED "
"on this servo. If you have not proven it yet, run: "
f"arm101 arm rezero {plan.joint} --verify",
]
),
json_mode=False,
Expand Down
103 changes: 82 additions & 21 deletions arm101/explain/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from __future__ import annotations

from arm101.hardware import arm_spec

_ROOT = """\
# arm101-cli

Expand Down Expand Up @@ -851,7 +853,14 @@
`servo_model`, and `gear_ratio`.
"""

_ARM_REZERO = """\
_ELBOW_ARC = arm_spec.REZERO_ARCS["elbow_flex"]

#: Rendered from :data:`arm101.hardware.arm_spec.REZERO_ARCS` at import, NOT copied
#: from it. The arc exists to be RE-MEASURED on hardware, and this text is what
#: prints to whoever is standing at the arm — so it must not be able to drift from
#: the table. A doc that names a measurement is a doc that goes stale; a doc that
#: RENDERS one cannot.
_ARM_REZERO = f"""\
# arm101-cli arm rezero <joint>

Shift a joint's **encoder zero** — the servo's `Ofs` / `Homing_Offset` register
Expand All @@ -872,17 +881,48 @@
reach lies on one side of it. The tick axis is linear again — genuinely, not by
assumption.

## Two frames, and everything turns on keeping them apart

A servo reports `Present = (Actual - Ofs) mod 4096`, so there are two tick frames
and they coincide only at `Ofs == 0` — which **no servo ships doing**. The
factory default is **85**, measured uniform across all six joints of the follower
(2026-07-12).

- **RAW** — the magnet on the shaft. The joint's walls, its unreachable arc, and
the seam (which lands where `Actual == Ofs`) all live here. Writing the offset
register moves none of it.
- **REPORTED** — what comes back over the wire, i.e. *everything* `arm read` and
`read_position` hand you. Shifted by whatever offset the servo holds.

`arm_spec.REZERO_ARCS` is **RAW ticks**, and the numbers below are RENDERED from
that table — not copied — so they cannot drift from it when the arm is
re-measured.

`elbow_flex`'s unreachable arc is currently `({_ELBOW_ARC.low}, {_ELBOW_ARC.high})`
(width {_ELBOW_ARC.high - _ELBOW_ARC.low}), leaving a reachable travel of about
{_ELBOW_ARC.travel_ticks} ticks: raw `[{_ELBOW_ARC.high}, 4095] ∪ [0, {_ELBOW_ARC.low}]`,
which *wraps* — which is exactly the fact a `[min, max]` pair cannot express, and the
whole of issue #35. Every live reading is converted (`raw = (reported + offset) mod
4096`) before it is compared against the arc.

Those walls were measured BY THE ARM, not by hand: `gentle_move` was driven past the
known travel and left to find each one by feel, stopping when `present_load` saturated.
A human stops when it *feels* firm; the arm presses to a fixed load every time, so its
walls are further out and repeatable. The arc is deliberately INSET from them, so a
harder push can never make the table contradict the arm.

## Why it commands no motion — the bootstrap problem

The tool that MAKES the axis linear cannot itself rely on the axis being linear.
"Drive the joint to mid-travel, then centre it" is the natural procedure and it
is exactly the one that must not run: from its rest position at raw ~126, a
linear goal of 3121 (its mid-travel) looks like a modest move and is in fact a
rotation *the long way round* — down through 0, across the whole 1894-tick arc
the joint cannot reach, and into a wall. So this verb reads where the joint
physically **is**, computes the offset from the joint's known unreachable arc (a
measured table fact, in `arm_spec.REZERO_ARCS`), and writes it. No goal position
is ever written.
is exactly the one that must not run: from a rest position on the far side of
its wrap, a
linear goal at its mid-travel looks like a modest move and is in fact a rotation
*the long way round* — down through 0, across the whole arc the joint
cannot reach, and into a wall. So this verb reads where the joint physically
**is**, computes the offset from the joint's known unreachable arc (a measured
table fact, in `arm_spec.REZERO_ARCS`), and writes it. No goal position is ever
written.

Torque is disabled before the EEPROM write and left off: a servo must not be
*holding* while its own frame of reference changes underneath it.
Expand All @@ -901,6 +941,21 @@
- **The other four — unnecessary.** Their encoders do not wrap inside their
travel, so there is no seam in the way and nothing to evict.

## The goal is a PLACE, not a number

"Re-zeroed" means **the seam is outside the joint's travel**, not "the register
holds the computed target". Any offset whose seam tick lands strictly inside the
arc has done the job; `arc.midpoint` is simply the one
with the most margin, and is what a *fresh* re-zero writes.

So a servo already holding a *different* evicting offset is **already fixed**, and
`--apply` reports a **no-op** and writes nothing. (Our follower holds `1073`, from
an earlier re-zero computed in the wrong frame; its seam sits at raw 1073, deep
inside the arc, and a sweep proved its travel continuous. Rewriting it
to the midpoint would spend an EEPROM write to slide a seam from one unreachable tick to
another.) A servo holding the **factory 85** is *not* fixed: raw 85 is inside
`elbow_flex`'s reachable `[0, 207]` band, which is issue #35 exactly.

## `--verify` — the seam-eviction proof

**Reading the offset back only proves it was APPLIED. It does not prove the seam
Expand All @@ -909,17 +964,22 @@
Present = (raw - Ofs) mod 4096 seam RELOCATES -> the fix works
Present = raw - Ofs (signed) seam STAYS -> the fix does NOTHING

Every source (and LeRobot's shipped SO-101 calibration) implies the first; no
primary Feetech source states it. `--verify` settles it: torque goes **off** and
stays off, a **human hand-moves** the joint through its entire travel, and the
verb polls `present_position` and asserts there is **no discontinuity anywhere**.
A human arm is the right instrument precisely because it is the only actuator
available that does not need a linear tick axis to work.
**It is the first — settled on hardware, 2026-07-12.** With `Ofs = 0` the sweep
came back `monotonic: False, discontinuities: 1`; with `Ofs = 1073` (inside the
arc) it came back `monotonic: True, discontinuities: 0` across its whole travel. No
primary Feetech source states the formula, so `--verify` remains the check — one
arm and one firmware revision is not every arm, and a verification that cannot
fail is not a verification.

`--verify`: torque goes **off** and stays off, a **human hand-moves** the joint
through its entire travel, and the verb polls `present_position` and asserts there
is **no discontinuity anywhere**. A human arm is the right instrument precisely
because it is the only actuator available that does not need a linear tick axis to
work.

It reports the range reached, whether the sweep was monotonic, and the largest
single-sample jump — a seam crossing is ~1949-4095 ticks; sensor noise and a
human hand are tens. It also measures `elbow_flex`'s **far wall** for the first
time (nothing could see across the seam before).
It reports the range reached (in both frames), whether the sweep was monotonic,
and the largest single-sample jump — a seam crossing is ~1781-4095 ticks; sensor
noise and a human hand are tens.

Four verdicts, because "did not fail" is not the same claim as "proved it works":

Expand Down Expand Up @@ -968,9 +1028,10 @@
- `1` user/usage error (an unknown joint, a joint that cannot be re-zeroed, a
`--duration` too short to collect two samples).
- `2` environment error (no port, SDK absent, comms failure), the offset failing
to read back, the servo holding an unrecognised offset, the joint reporting a
raw position inside its own unreachable arc — **and the `seam-not-evicted`
verdict**, which is a stop condition, not a retryable error.
to read back, the joint reporting a raw position inside its own unreachable arc
— **and the `seam-not-evicted` verdict**, which is a stop condition, not a
retryable error. (An *unfamiliar* offset is no longer an error: the verb reads
whatever the register holds, converts out of it, and reasons in raw ticks.)

## Hardware / TTY behavior

Expand Down
Loading
Loading