From dd96ba52f0477ac567871c61a9e7afd2abb183e6 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sun, 12 Jul 2026 15:13:26 +0300 Subject: [PATCH 1/7] =?UTF-8?q?fix(bus):=20a=20dropped=20packet=20must=20b?= =?UTF-8?q?e=20a=20CliError,=20not=20an=20IndexError=20=E2=80=94=20and=20r?= =?UTF-8?q?eads=20retry,=20writes=20never=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed live on hardware today: scservo_sdk's read2ByteTxRx can raise a bare IndexError internally on a short/corrupt packet (it indexes data[0]/ data[1] whenever result==COMM_SUCCESS, even when the buffer is short), escaping this repo's no-traceback-ever-leaks contract before the result/error check ever runs. FeetechBus reads (read_position, the shared _read_register path behind read_info/read_offset/read_torque_limit, and read_lock) now funnel through _sdk_read (converts any bare SDK exception into a CliError) and _retry_read (bounded 3-attempt retry, since a read is idempotent) — OverloadError is never retried or converted, it propagates on the first occurrence exactly as before. Second finding: a read issued immediately after an EEPROM write (the Lock dance) can return a silently-wrong-but-plausible value — a genuine offset-0/position-3387 write read back as position 0 about 0.2s later, then correctly and stably moments after. _set_lock now settles (_EEPROM_SETTLE_SECONDS) after closing the Lock, before returning control to a caller that might read straight back. Writes are never retried (a "failed" write may have landed — see #21's Lock bug and #38's id-transfer-window bug) — pinned by call-count assertions in the new tests. --- arm101/hardware/bus.py | 264 ++++++++++++++++--- tests/test_bus_read_robustness.py | 424 ++++++++++++++++++++++++++++++ 2 files changed, 652 insertions(+), 36 deletions(-) create mode 100644 tests/test_bus_read_robustness.py diff --git a/arm101/hardware/bus.py b/arm101/hardware/bus.py index 69f07bb..f69d557 100644 --- a/arm101/hardware/bus.py +++ b/arm101/hardware/bus.py @@ -15,6 +15,7 @@ import abc import contextlib +import time from types import TracebackType from typing import TYPE_CHECKING @@ -84,6 +85,58 @@ def require_sdk() -> None: _FAKEBUS_NOT_OPEN_MSG = "FakeBus is not open; call open() first." _FAKEBUS_NOT_OPEN_REMEDIATION = "Call FakeBus.open() or use it as a context manager." +# --------------------------------------------------------------------------- +# Read-retry policy — a dropped/corrupt packet is normal; a read is idempotent +# --------------------------------------------------------------------------- + +#: Bounded retry count for :class:`FeetechBus` reads (see +#: :meth:`FeetechBus._retry_read`). A read costs nothing to repeat, and a +#: dropped/corrupt packet on an otherwise healthy bus turned out to be common +#: enough that every ad-hoc probe script written during this session's live +#: hardware run ended up hand-rolling exactly this loop — 3 is the number +#: those scripts converged on independently. +#: +#: NEVER applied to writes. :meth:`FeetechBus.write_id_baudrate`, +#: :meth:`FeetechBus.write_baudrate`, and :meth:`FeetechBus.write_offset` all +#: retry nothing, because a "failed" write may in fact have landed — this is +#: precisely how issue #21's Lock-register bug and #38's id-transfer-window +#: bug arose. A read is safe to repeat; a write is not safe to repeat blind. +_READ_RETRY_ATTEMPTS: int = 3 + +#: Delay between read-retry attempts. Short enough to stay invisible to a +#: caller polling at ``gentle_move``'s ~25 ms cadence (``_DEFAULT_POLL_INTERVAL`` +#: in ``arm101/hardware/gentle.py``), long enough to let a transiently busy bus +#: clear before the next attempt. +_READ_RETRY_DELAY_SECONDS: float = 0.05 + +# --------------------------------------------------------------------------- +# EEPROM write settle — a write can leave the servo briefly unable to answer +# --------------------------------------------------------------------------- + +#: Delay applied after CLOSING the EEPROM Lock (addr 55 -> 1) — the last step +#: of every EEPROM write dance (:meth:`FeetechBus.write_id_baudrate`, +#: :meth:`FeetechBus.write_baudrate`, :meth:`FeetechBus.write_offset`) — +#: before the next bus call is allowed to proceed. +#: +#: Measured live on hardware, the same session as the packet-drop finding +#: above: a ``write_offset(3, 0)`` landed correctly (verified independently — +#: the servo genuinely held offset 0 and position 3387), yet a +#: ``read_position`` issued roughly 0.2 s later returned a silently-WRONG +#: ``0``. A re-read moments later returned the correct 3387, repeatedly and +#: stably. Unlike a dropped packet — which fails loudly and is what the +#: read-retry policy above exists for — this failure mode returns a +#: *plausible* value: position 0 looks exactly like a real reading, and +#: nothing downstream can tell it apart from the truth by inspecting it alone. +#: +#: 0.2 s was observed NOT to be enough; this constant is a deliberately +#: conservative margin past that observed failure point, not a proven- +#: sufficient bound — the true recovery latency is only bracketed between +#: "0.2 s: still wrong" and "a few tenths of a second later: correct and +#: stable". Kept small on purpose: it only costs anything on the already-rare +#: EEPROM-write path (id/baud/offset), never on the poll-rate reads +#: ``gentle_move`` issues roughly every 25 ms. +_EEPROM_SETTLE_SECONDS: float = 0.3 + # --------------------------------------------------------------------------- # Baud-rate mapping — hoisted to module scope so callers can validate/enumerate # --------------------------------------------------------------------------- @@ -845,6 +898,102 @@ def _status_error( remediation=remediation, ) + def _sdk_read(self, fn: object, motor: int, addr: int, action: str) -> "tuple[int, int, int]": + """Call an SDK read function; convert any bare exception it raises into a CliError. + + *fn* is one of ``self._packet_handler.read1ByteTxRx`` / + ``read2ByteTxRx``. On a healthy exchange it returns + ``(value, result, error)`` and this method is a passthrough. On a + SHORT or CORRUPT packet the vendor SDK can instead *raise* — observed + live on this session's hardware, mid-session, on a bus that was + otherwise perfectly healthy:: + + File ".../scservo_sdk/protocol_packet_handler.py", line 326, in read2ByteTxRx + data_read = SCS_MAKEWORD(data[0], data[1]) if (result == COMM_SUCCESS) else 0 + ~~~~^^^ + IndexError: list index out of range + + The SDK checks ``result == COMM_SUCCESS`` and only THEN indexes + ``data[0]``/``data[1]`` — so a response buffer shorter than 2 bytes + raises ``IndexError`` from *inside* ``read2ByteTxRx``, before this + bus's own ``result != 0 or error != 0`` check downstream ever gets a + chance to run. Nothing about the bus itself is wrong; the packet was + simply dropped or truncated in flight — the read1ByteTxRx path has the + identical shape and the identical exposure. + + This repo's one hard rule (``arm101/cli/_errors.py`` — no Python + traceback ever leaks to stderr) applies just as much to an exception + the *vendor SDK* raises as to one this codebase raises itself, so any + exception that is not already a :class:`CliError` is caught here and + converted into one. The caller (:meth:`_retry_read`) treats it exactly + like a comms failure and retries — a dropped/corrupt packet on a + healthy bus is transient, and a read is idempotent, so retrying costs + nothing wrong. + + Raises + ------ + CliError(EXIT_ENV_ERROR) + If *fn* raises anything other than a :class:`CliError`. + """ + from arm101.cli._errors import EXIT_ENV_ERROR, CliError + + try: + return fn(self._port_handler, motor, addr) # type: ignore[operator] + except CliError: + raise + except Exception as exc: # noqa: BLE001 - the SDK can raise ~anything internally + raise CliError( + code=EXIT_ENV_ERROR, + message=( + f"{action}: the SDK raised {type(exc).__name__} ({exc}) reading " + f"register {addr} on motor {motor} — the packet was dropped or " + "corrupt." + ), + remediation=( + "A single dropped/corrupt packet is normal on an otherwise " + "healthy bus; the read is idempotent, so it is safe to retry." + ), + ) from exc + + def _retry_read(self, attempt: "object") -> int: + """Call *attempt* (a zero-arg callable returning ``int``) with bounded retry. + + A read is idempotent, so retrying a dropped/corrupt packet costs + nothing wrong — every ad-hoc probe script written during this + session's live hardware run ended up hand-rolling exactly this loop + (see :data:`_READ_RETRY_ATTEMPTS`). Only the LAST failure ever + propagates; every earlier one is swallowed after a short delay + (:data:`_READ_RETRY_DELAY_SECONDS`). + + :class:`OverloadError` is the one failure NEVER retried: it is a + real, latched servo fault (status error bit 5), not a dropped packet, + and callers (``gentle_move``, ``arm rezero``) rely on catching it + *immediately* to run their own overload recovery — silently eating an + attempt or two on it here would only delay that recovery, and the + latch will not clear itself between attempts anyway. + + Raises + ------ + OverloadError + Immediately, on the first occurrence — never retried. + CliError(EXIT_ENV_ERROR) + If every attempt fails for any other reason. + """ + last_error: "CliError | None" = None + for attempt_number in range(1, _READ_RETRY_ATTEMPTS + 1): + try: + return attempt() # type: ignore[operator] + except OverloadError: + raise + except CliError as exc: + last_error = exc + if attempt_number < _READ_RETRY_ATTEMPTS: + time.sleep(_READ_RETRY_DELAY_SECONDS) + assert ( + last_error is not None + ) # pragma: no cover - loop always assigns before falling through + raise last_error + def _import_sdk(self) -> object: """Lazy-import scservo_sdk; raise CliError if absent.""" from arm101.cli._errors import EXIT_ENV_ERROR, CliError @@ -869,6 +1018,18 @@ def _set_lock(self, motor: int, locked: bool) -> None: while Lock=1 updates the live register but is NOT committed to EEPROM, so the value reverts to the stored one on the next power-up. Re-lock (``locked=True``) afterwards to restore write-protection. + + Re-locking also SETTLES (:data:`_EEPROM_SETTLE_SECONDS`) before + returning — see that constant for the hardware measurement motivating + it. A caller that turns around and reads a register straight back can + otherwise catch the servo mid-recovery from the EEPROM write and be + handed a plausible-looking but WRONG value (observed: a position + register that genuinely held 3387 read back as 0 roughly 0.2 s after + the write that changed nothing about it). The settle applies only to + the CLOSING write (``locked=True``) — the one that marks the end of an + EEPROM write dance, success or best-effort failure path alike — never + to the opening unlock, which is followed by more EEPROM traffic, not + by a caller's read. """ self._require_open() result, error = self._packet_handler.write1ByteTxRx( # type: ignore[union-attr] @@ -879,6 +1040,8 @@ def _set_lock(self, motor: int, locked: bool) -> None: raise self._status_error( motor, result, error, f"Failed to {state} EEPROM for motor {motor}" ) + if locked: + time.sleep(_EEPROM_SETTLE_SECONDS) # ------------------------------------------------------------------ # MotorBus interface @@ -963,6 +1126,13 @@ def read_position(self, motor: int) -> int: add the offset back, because the corrected frame IS the frame goals are commanded in. + A SHORT/CORRUPT packet on the wire (the SDK's ``read2ByteTxRx`` can + raise a bare ``IndexError`` rather than returning a clean failed + ``result`` — see :meth:`_sdk_read`) or a comms failure is retried up + to :data:`_READ_RETRY_ATTEMPTS` times before raising (see + :meth:`_retry_read`); :class:`OverloadError` is the one failure that + is never retried. + Returns ------- int @@ -973,23 +1143,28 @@ def read_position(self, motor: int) -> int: # STS3215 present-position address = 56, 2 bytes. _ADDR_PRESENT_POSITION = 56 - value, result, error = self._packet_handler.read2ByteTxRx( # type: ignore[union-attr] - self._port_handler, motor, _ADDR_PRESENT_POSITION - ) - if result != 0 or error != 0: # any non-zero comm result or servo error means failure - raise self._status_error( - motor, result, error, f"Read position failed for motor {motor}" + def _attempt() -> int: + action = f"Read position failed for motor {motor}" + value, result, error = self._sdk_read( + self._packet_handler.read2ByteTxRx, # type: ignore[union-attr] + motor, + _ADDR_PRESENT_POSITION, + action, ) - # Mask to 12 bits. DELIBERATELY unchanged by the offset work (issue #35): - # Present_Position is itself sign-magnitude on bit 15, so this mask would - # SWALLOW a negative reading. That only matters if the firmware reports the - # corrected position as an unwrapped signed subtraction rather than reducing - # it modulo 4096 — the one unproven assumption behind the whole re-zero - # (docs/spikes/sts3215-offset-register.md §4). Every source points at the - # modular reading, in which case the value is always in [0, 4095] and this - # mask is a no-op. If the hardware test (step 10) says otherwise, the re-zero - # does not work at all and THIS mask is the next thing to fix. - return int(value) & 0x0FFF + if result != 0 or error != 0: # non-zero comm result or servo error = failure + raise self._status_error(motor, result, error, action) + # Mask to 12 bits. DELIBERATELY unchanged by the offset work (issue #35): + # Present_Position is itself sign-magnitude on bit 15, so this mask would + # SWALLOW a negative reading. That only matters if the firmware reports the + # corrected position as an unwrapped signed subtraction rather than reducing + # it modulo 4096 — the one unproven assumption behind the whole re-zero + # (docs/spikes/sts3215-offset-register.md §4). Every source points at the + # modular reading, in which case the value is always in [0, 4095] and this + # mask is a no-op. If the hardware test (step 10) says otherwise, the re-zero + # does not work at all and THIS mask is the next thing to fix. + return int(value) & 0x0FFF + + return self._retry_read(_attempt) def write_id_baudrate(self, motor: int, new_id: int, baudrate: int) -> None: """Write servo ID and baud-rate to the motor's EEPROM. @@ -1165,20 +1340,29 @@ def write_baudrate(self, motor: int, baudrate: int) -> None: } def _read_register(self, motor: int, addr: int, length: int) -> int: - """Read a 1- or 2-byte register; raise CliError on a comms failure.""" - if length == 1: - value, result, error = self._packet_handler.read1ByteTxRx( # type: ignore[union-attr] - self._port_handler, motor, addr - ) - else: - value, result, error = self._packet_handler.read2ByteTxRx( # type: ignore[union-attr] - self._port_handler, motor, addr - ) - if result != 0 or error != 0: - raise self._status_error( - motor, result, error, f"Read of register {addr} failed for motor {motor}" + """Read a 1- or 2-byte register with bounded retry; raise CliError on failure. + + Retries up to :data:`_READ_RETRY_ATTEMPTS` times on ANY read failure — + a comms-level ``result``/``error`` failure, or a bare exception the + SDK raised internally (:meth:`_sdk_read` — e.g. the ``IndexError`` a + short/corrupt packet triggers inside ``read2ByteTxRx``) — except + :class:`OverloadError`, which is a latched servo fault rather than a + dropped packet and is never retried (:meth:`_retry_read`). + """ + + def _attempt() -> int: + reader = ( + self._packet_handler.read1ByteTxRx # type: ignore[union-attr] + if length == 1 + else self._packet_handler.read2ByteTxRx # type: ignore[union-attr] ) - return int(value) + action = f"Read of register {addr} failed for motor {motor}" + value, result, error = self._sdk_read(reader, motor, addr, action) + if result != 0 or error != 0: + raise self._status_error(motor, result, error, action) + return int(value) + + return self._retry_read(_attempt) def scan(self, ids: "list[int] | None" = None) -> "list[int]": """Ping candidate *ids* and return those that respond (read-only). @@ -1276,6 +1460,9 @@ def write_goal_position(self, motor: int, position: int) -> None: def read_lock(self, motor: int) -> int: """Read the STS3215 Lock register (address 55, 1 byte) for *motor*. + Bounded-retried like every other read on this bus — see + :meth:`_read_register` / :meth:`_retry_read`. + Returns ------- int @@ -1283,14 +1470,19 @@ def read_lock(self, motor: int) -> int: """ self._require_open() - value, result, error = self._packet_handler.read1ByteTxRx( # type: ignore[union-attr] - self._port_handler, motor, ADDR_LOCK - ) - if result != 0 or error != 0: - raise self._status_error( - motor, result, error, f"Read lock register failed for motor {motor}" + def _attempt() -> int: + action = f"Read lock register failed for motor {motor}" + value, result, error = self._sdk_read( + self._packet_handler.read1ByteTxRx, # type: ignore[union-attr] + motor, + ADDR_LOCK, + action, ) - return int(value) + if result != 0 or error != 0: + raise self._status_error(motor, result, error, action) + return int(value) + + return self._retry_read(_attempt) def write_acceleration(self, motor: int, value: int) -> None: """Write the goal acceleration for *motor*. diff --git a/tests/test_bus_read_robustness.py b/tests/test_bus_read_robustness.py new file mode 100644 index 0000000..8f3bce7 --- /dev/null +++ b/tests/test_bus_read_robustness.py @@ -0,0 +1,424 @@ +"""Tests for FeetechBus read robustness: a dropped packet is a CliError, not +an IndexError, and reads retry while writes never do. + +TDD: written before the corresponding bus.py changes; they must fail against +the pre-fix code and drive the implementation. + +Why this module exists — a real traceback, seen live on hardware today +------------------------------------------------------------------------ +``FeetechBus._read_register`` calls the vendor SDK directly:: + + value, result, error = self._packet_handler.read2ByteTxRx(self._port_handler, motor, addr) + +and the SDK does this internally (``scservo_sdk/protocol_packet_handler.py``):: + + data_read = SCS_MAKEWORD(data[0], data[1]) if (result == COMM_SUCCESS) else 0 + +On a SHORT/CORRUPT packet, ``data`` can have fewer than 2 elements while +``result`` is still ``COMM_SUCCESS``, so the SDK raises a bare +``IndexError: list index out of range`` from *inside* ``read2ByteTxRx`` — +before this bus's own ``result != 0 or error != 0`` check ever runs. This was +observed live, mid-session, on a bus that was otherwise perfectly healthy. +This repo's one hard rule (``arm101/cli/_errors.py``) is that no Python +traceback ever leaks to stderr; that must hold for exceptions the *vendor +SDK* raises internally too, not only ones this codebase raises itself. + +A second, related finding: a read issued immediately after an EEPROM write +can return silently-WRONG data (a plausible-looking ``0`` in place of the +real position) rather than failing loudly — covered by +``test_feetech_eeprom_write_settle.py``... no such file; the settle tests +live at the bottom of this module instead, next to the retry tests they are +easiest to reason about alongside. + +See ``arm101/hardware/bus.py``'s ``_sdk_read`` / ``_retry_read`` / `_set_lock` +docstrings and the ``_READ_RETRY_*`` / ``_EEPROM_SETTLE_SECONDS`` constants +for the implementation this file drives. +""" + +from __future__ import annotations + +import pytest + +from arm101.cli._errors import CliError +from arm101.hardware.bus import ( + _EEPROM_SETTLE_SECONDS, + _READ_RETRY_ATTEMPTS, + ADDR_HOMING_OFFSET, + ADDR_LOCK, + FeetechBus, + OverloadError, +) + +# --------------------------------------------------------------------------- +# Fake packet-handler — sequenced reads + fail-by-address writes +# --------------------------------------------------------------------------- + + +class _SequencedPacket: + """Packet-handler stub whose READS play back a scripted, PER-CALL sequence. + + Every other packet-handler fake in this suite (``_ScriptedPacket`` in + ``test_bus_overload.py``, ``_RecordingPacket`` in ``test_bus.py`` and + ``test_bus_offset.py``) returns the SAME ``(result, error)`` / + ``(value, result, error)`` on every call. Retry behaviour is defined by + what happens ACROSS successive calls to the same register — fail, fail, + succeed — and a fixture that cannot vary its answer by call number cannot + express that, which is the one genuinely new capability this fake adds. + + Each element of *read_outcomes* is either: + + * an ``Exception`` INSTANCE — raised, modelling a bare SDK exception + exactly as observed live (``IndexError`` from a short/corrupt packet — + ``scservo_sdk/protocol_packet_handler.py:326``). + * a ``(value, result, error)`` tuple — returned normally, matching the + shape every other read fake here already uses. + + Calls past the end of *read_outcomes* repeat the LAST entry, so a test + only has to script as many steps as it cares about. + + *write_fail_addrs* mirrors ``_RecordingPacket.fail_addrs`` in + ``test_bus_offset.py``: addresses in the set report a comms failure + (``result=1``) on EVERY write; everything else succeeds. Every write call + is recorded in ``.writes``, in order — used by the "a write is never + retried" tests, which assert directly on how many times a given address + was written. + """ + + def __init__(self, read_outcomes, write_fail_addrs=None): + self._read_outcomes = list(read_outcomes) + self._write_fail_addrs = set(write_fail_addrs or ()) + self.read_calls = 0 + self.writes: list[tuple[int, int, int]] = [] + + def _next_read(self): + idx = min(self.read_calls, len(self._read_outcomes) - 1) + outcome = self._read_outcomes[idx] + self.read_calls += 1 + if isinstance(outcome, Exception): + raise outcome + return outcome + + def read1ByteTxRx(self, port, motor, addr): # noqa: N802 - SDK spelling + return self._next_read() + + def read2ByteTxRx(self, port, motor, addr): # noqa: N802 - SDK spelling + return self._next_read() + + def _write_result(self, addr): + return (1, 0) if addr in self._write_fail_addrs else (0, 0) + + def write1ByteTxRx(self, port, motor, addr, val): # noqa: N802 - SDK spelling + self.writes.append((motor, addr, val)) + return self._write_result(addr) + + def write2ByteTxRx(self, port, motor, addr, val): # noqa: N802 - SDK spelling + self.writes.append((motor, addr, val)) + return self._write_result(addr) + + +def _open_feetech(packet: "_SequencedPacket") -> FeetechBus: + """A FeetechBus wired to *packet*, marked open, with no serial port involved.""" + bus = FeetechBus(port="/dev/ttyUSB_fake") + bus._packet_handler = packet + bus._port_handler = object() + bus._open = True + return bus + + +# --------------------------------------------------------------------------- +# 1. A short/corrupt packet becomes a CliError, never a raw IndexError +# --------------------------------------------------------------------------- + + +def test_feetech_read_position_indexerror_becomes_cli_error_not_a_raw_traceback(): + """The exact failure observed live: read2ByteTxRx raises bare IndexError. + + ``pytest.raises(CliError)`` only passes if a ``CliError`` — and nothing + else — comes out of ``read_position``; an uncaught ``IndexError`` would + fail this test with the raw traceback, which is exactly the bug. + """ + packet = _SequencedPacket(read_outcomes=[IndexError("list index out of range")]) + bus = _open_feetech(packet) + + with pytest.raises(CliError) as exc: + bus.read_position(motor=6) + + assert exc.value.code == 2 # EXIT_ENV_ERROR + assert "IndexError" in exc.value.message + assert "retry" in exc.value.remediation.lower() or "retry" in exc.value.message.lower() + # Bounded: exhausted every retry attempt, not one and not unboundedly many. + assert packet.read_calls == _READ_RETRY_ATTEMPTS + + +def test_feetech_read_lock_indexerror_becomes_cli_error_not_a_raw_traceback(): + """The 1-byte read path (read_lock -> read1ByteTxRx) has the identical shape.""" + packet = _SequencedPacket(read_outcomes=[IndexError("list index out of range")]) + bus = _open_feetech(packet) + + with pytest.raises(CliError) as exc: + bus.read_lock(motor=6) + + assert exc.value.code == 2 # EXIT_ENV_ERROR + assert "IndexError" in exc.value.message + + +def test_feetech_read_register_indexerror_becomes_cli_error_not_a_raw_traceback(): + """`_read_register` (read_info / read_offset / read_torque_limit's shared path).""" + packet = _SequencedPacket(read_outcomes=[IndexError("list index out of range")]) + bus = _open_feetech(packet) + + with pytest.raises(CliError) as exc: + bus.read_torque_limit(motor=4) + + assert exc.value.code == 2 # EXIT_ENV_ERROR + + +# --------------------------------------------------------------------------- +# 2. Retry: a read that eventually succeeds returns the value +# --------------------------------------------------------------------------- + + +def test_feetech_read_position_retries_and_returns_value_on_third_attempt(): + """Fails twice (one SDK exception, one comms failure), succeeds on the third.""" + packet = _SequencedPacket( + read_outcomes=[ + IndexError("list index out of range"), # attempt 1: bare SDK exception + (0, 1, 0), # attempt 2: comms failure (nonzero result) + (1234, 0, 0), # attempt 3: success + ] + ) + bus = _open_feetech(packet) + + assert bus.read_position(motor=6) == 1234 + assert packet.read_calls == 3 + + +def test_feetech_read_register_retries_and_returns_value_on_third_attempt(): + """Same retry-then-succeed shape via `_read_register` (read_torque_limit).""" + packet = _SequencedPacket( + read_outcomes=[ + IndexError("list index out of range"), + (0, 1, 0), + (750, 0, 0), + ] + ) + bus = _open_feetech(packet) + + assert bus.read_torque_limit(motor=4) == 750 + assert packet.read_calls == 3 + + +# --------------------------------------------------------------------------- +# 3. Retry is bounded: every attempt failing raises CliError +# --------------------------------------------------------------------------- + + +def test_feetech_read_position_all_attempts_fail_raises_cli_error(): + packet = _SequencedPacket(read_outcomes=[(0, 1, 0)]) # comms failure, every call + bus = _open_feetech(packet) + + with pytest.raises(CliError) as exc: + bus.read_position(motor=6) + + assert exc.value.code == 2 # EXIT_ENV_ERROR + assert packet.read_calls == _READ_RETRY_ATTEMPTS # bounded — not infinite + + +def test_feetech_read_lock_all_attempts_fail_raises_cli_error(): + packet = _SequencedPacket(read_outcomes=[IndexError("boom")]) + bus = _open_feetech(packet) + + with pytest.raises(CliError): + bus.read_lock(motor=6) + + assert packet.read_calls == _READ_RETRY_ATTEMPTS + + +# --------------------------------------------------------------------------- +# 4. A WRITE is never retried — pinned hard, by call count +# --------------------------------------------------------------------------- + + +def test_feetech_write_goal_position_is_never_retried_on_failure(): + """A failing write must be attempted EXACTLY ONCE. + + A read is idempotent and safe to repeat; a write is not — a "failed" + write may in fact have landed (issue #21's Lock bug, #38's + id-transfer-window bug both arose from exactly that ambiguity). Retrying + a write blind could double-apply a change or race a write that already + committed, so this is asserted directly on the call count rather than + merely inferred from timing. + """ + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)], write_fail_addrs={42}) + + bus = _open_feetech(packet) + + with pytest.raises(CliError): + bus.write_goal_position(motor=3, position=100) + + goal_writes = [w for w in packet.writes if w[1] == 42] + assert len(goal_writes) == 1 # exactly one attempt — no retry + + +def test_feetech_write_offset_eeprom_data_write_is_never_retried_on_failure(): + """The addr-31 EEPROM write itself is never retried, specifically. + + Distinct from the goal-position case above: this is the persistent + EEPROM path the whole "never retry a write" rule exists to protect (see + ``FeetechBus.write_offset``'s docstring on PR #21 / issue #38). + """ + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)], write_fail_addrs={ADDR_HOMING_OFFSET}) + + bus = _open_feetech(packet) + + with pytest.raises(CliError): + bus.write_offset(motor=3, offset=100) + + offset_writes = [w for w in packet.writes if w[1] == ADDR_HOMING_OFFSET] + assert len(offset_writes) == 1 # exactly one attempt at the EEPROM write itself + + +def test_feetech_write_id_baudrate_baud_write_is_never_retried_on_failure(): + """The addr-6 baud write inside write_id_baudrate is never retried either.""" + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)], write_fail_addrs={6}) + + bus = _open_feetech(packet) + + with pytest.raises(CliError): + bus.write_id_baudrate(motor=1, new_id=2, baudrate=1_000_000) + + baud_writes = [w for w in packet.writes if w[1] == 6] + assert len(baud_writes) == 1 # exactly one attempt — no retry + + +# --------------------------------------------------------------------------- +# 5. OverloadError is never retried, and never converted +# --------------------------------------------------------------------------- + + +def test_feetech_read_position_overload_propagates_immediately_not_retried(): + """A latched overload (status error bit 5) is a REAL fault, not a dropped packet. + + Callers (``gentle_move``, ``arm rezero``) rely on catching it immediately + to run their own recovery, so it must reach them on the FIRST attempt — + silently eating one or two attempts on it here would only delay that + recovery, and the latch will not clear itself between retries anyway. + """ + packet = _SequencedPacket(read_outcomes=[(0, 0, 32)]) # overload bit set + bus = _open_feetech(packet) + + with pytest.raises(OverloadError) as exc: + bus.read_position(motor=6) + + assert exc.value.motor == 6 + assert exc.value.error_byte == 32 + assert packet.read_calls == 1 # NOT retried + + +def test_feetech_read_register_overload_propagates_immediately_not_retried(): + packet = _SequencedPacket(read_outcomes=[(0, 0, 32)]) + bus = _open_feetech(packet) + + with pytest.raises(OverloadError): + bus.read_torque_limit(motor=4) + + assert packet.read_calls == 1 + + +# --------------------------------------------------------------------------- +# 6. EEPROM write settle — a brief pause after the closing re-lock +# --------------------------------------------------------------------------- + + +def test_feetech_set_lock_relock_settles_after_a_successful_write(monkeypatch): + """Closing the Lock (locked=True) sleeps _EEPROM_SETTLE_SECONDS before returning.""" + import arm101.hardware.bus as bus_module + + sleeps: list[float] = [] + monkeypatch.setattr(bus_module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)]) + bus = _open_feetech(packet) + + bus._set_lock(motor=3, locked=True) + + assert sleeps == [_EEPROM_SETTLE_SECONDS] + + +def test_feetech_set_lock_unlock_does_not_settle(monkeypatch): + """Opening the Lock (locked=False) is followed by MORE EEPROM traffic, not a + caller's read — it must not pay the settle cost.""" + import arm101.hardware.bus as bus_module + + sleeps: list[float] = [] + monkeypatch.setattr(bus_module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)]) + bus = _open_feetech(packet) + + bus._set_lock(motor=3, locked=False) + + assert sleeps == [] + + +def test_feetech_write_offset_settles_exactly_once_after_the_relock(monkeypatch): + """End to end: write_offset's closing re-lock settles; nothing else does.""" + import arm101.hardware.bus as bus_module + + sleeps: list[float] = [] + monkeypatch.setattr(bus_module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)]) + bus = _open_feetech(packet) + + bus.write_offset(motor=3, offset=100) + + assert sleeps == [_EEPROM_SETTLE_SECONDS] + + +def test_feetech_failed_offset_write_still_settles_on_the_best_effort_relock(monkeypatch): + """Even on a FAILED EEPROM write, the best-effort re-lock still settles — + the servo may still be recovering from whatever landed before the failure.""" + import arm101.hardware.bus as bus_module + + sleeps: list[float] = [] + monkeypatch.setattr(bus_module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)], write_fail_addrs={ADDR_HOMING_OFFSET}) + bus = _open_feetech(packet) + + with pytest.raises(CliError): + bus.write_offset(motor=3, offset=100) + + assert sleeps == [_EEPROM_SETTLE_SECONDS] # the best-effort relock still ran and settled + + +def test_feetech_write_goal_position_does_not_settle(monkeypatch): + """A RAM write with no Lock dance (write_goal_position) never pays the EEPROM settle.""" + import arm101.hardware.bus as bus_module + + sleeps: list[float] = [] + monkeypatch.setattr(bus_module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)]) + bus = _open_feetech(packet) + + bus.write_goal_position(motor=3, position=100) + + assert sleeps == [] + + +def test_feetech_clear_overload_does_not_settle(monkeypatch): + """clear_overload (addr 40, RAM) never touches the Lock register at all.""" + import arm101.hardware.bus as bus_module + + sleeps: list[float] = [] + monkeypatch.setattr(bus_module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + packet = _SequencedPacket(read_outcomes=[(0, 0, 0)]) + bus = _open_feetech(packet) + + bus.clear_overload(motor=3) + + assert sleeps == [] + assert ADDR_LOCK not in {addr for _motor, addr, _val in packet.writes} From 18ca16be4ffea9f19e626776cd5b13e61c5835c5 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sun, 12 Jul 2026 15:24:34 +0300 Subject: [PATCH 2/7] =?UTF-8?q?fix(rezero):=20the=20unreachable=20arc=20is?= =?UTF-8?q?=20RAW=20ticks=20=E2=80=94=20the=20target=20was=20computed=20in?= =?UTF-8?q?=20the=20wrong=20frame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-zero shipped in PR #40 worked, but by luck, not by construction. Two hardware findings, follower /dev/ttyACM1, 2026-07-12: * The servos ship holding `Ofs = 85`, not the factory 0 the spike assumed — uniform across all six joints, so a vendor default. `Present = Actual − Ofs`, confirmed by a reversible probe. * `elbow_flex`'s far wall, measured for the first time by a torque-off hand sweep with the seam evicted: travel is 2196 ticks, reported 1034..3230 at `Ofs = 1073`. The same sweep CLOSES the spike's §4 caveat — `Ofs=0` gave `discontinuities: 1`, `Ofs=1073` gave `0`. The correction IS reduced mod 4096; the seam relocates. The bug: REZERO_ARCS held `elbow_flex: (126, 2020)`, and those ticks were read off a servo already holding Ofs=85 — they are REPORTED-frame ticks, used as if they were RAW. The offset register operates in the raw frame (the seam lands where Actual == Ofs), so the target was off by the pre-existing offset. It landed inside the true arc anyway, with ~866 ticks of margin — the most dangerous way for a frame bug to behave, because every read-back looked right. * `UnreachableArc` is RAW ticks, said loudly: a property of the joint's travel, independent of whatever is in the register. Arc corrected to the measured `(207, 2107)` — raw travel `[2107, 4095] ∪ [0, 207]`, which wraps. Midpoint 1157. * `plan_rezero` is frame-aware: reads the live offset, converts `raw = (reported + offset) mod 4096`, reasons entirely in raw. The `current in (0, target)` refusal is GONE — it blocked the verb outright on a factory-fresh servo, i.e. on every real SO-101. * The goal is "the seam is OUT of the joint's travel", not "the offset equals N". `UnreachableArc.evicts()` is the new question, and an offset already inside the arc is a NO-OP — our arm holds 1073 and is left alone rather than rewritten to 1157 for cosmetic centring. `SweepReport.rezeroed` asks the same question, so --verify on this arm PASSES instead of reporting "inconclusive". Every safety property holds: no motion on any path, clear_overload first, torque-off → unlock → addr 31 → re-lock, addr 9/11 never touched, wrist_roll still refused with its own distinct reason. 1222 tests pass (was 1202); four linters and teken --strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6 --- arm101/cli/_commands/arm.py | 55 ++- arm101/explain/catalog.py | 78 +++- arm101/hardware/arm_spec.py | 282 +++++++++++--- arm101/hardware/rezero.py | 406 +++++++++++++------ docs/hardware-rezero-procedure.md | 153 +++++--- docs/spikes/sts3215-offset-register.md | 19 +- tests/test_arm_rezero_cli.py | 160 +++++++- tests/test_bus_offset.py | 11 +- tests/test_rezero.py | 517 ++++++++++++++++++++----- 9 files changed, 1322 insertions(+), 359 deletions(-) diff --git a/arm101/cli/_commands/arm.py b/arm101/cli/_commands/arm.py index 4922515..2f37146 100644 --- a/arm101/cli/_commands/arm.py +++ b/arm101/cli/_commands/arm.py @@ -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), } @@ -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.", @@ -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']}" @@ -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] @@ -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( @@ -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, @@ -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, diff --git a/arm101/explain/catalog.py b/arm101/explain/catalog.py index 2d25e26..a9a8332 100644 --- a/arm101/explain/catalog.py +++ b/arm101/explain/catalog.py @@ -872,17 +872,36 @@ 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**: `elbow_flex`'s arc is `(207, 2107)`, from +a torque-off hand sweep that measured its travel at 2196 ticks — raw +`[2107, 4095] ∪ [0, 207]`, which *wraps*, which is exactly the fact a `[min, max]` +pair cannot express. Every live reading is converted (`raw = (reported + offset) +mod 4096`) before it is compared against it. + ## 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. +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 1900-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. 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. @@ -901,6 +920,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` (**1157** for `elbow_flex`) 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 `(207, 2107)`, and a hand sweep proved its travel continuous. Rewriting it +to 1157 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 @@ -909,17 +943,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 all 2196 ticks. 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": @@ -968,9 +1007,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 diff --git a/arm101/hardware/arm_spec.py b/arm101/hardware/arm_spec.py index c9b68c6..988cc81 100644 --- a/arm101/hardware/arm_spec.py +++ b/arm101/hardware/arm_spec.py @@ -45,10 +45,11 @@ (3049 → 2749) converged in 2078 ms, exactly as expected. The seam, not the joint, was the fault. -``elbow_flex`` has real mechanical walls (a measured ~2020-tick span), so it -is fixed by an encoder **re-zero**: relocate the seam into the arc the joint -physically cannot reach, and every reachable tick is then on one side of it — -linear again. ``wrist_roll`` cannot take that path. Exploration found **no +``elbow_flex`` has real mechanical walls (a measured 2196-tick travel, running +raw ``2107 -> 4095 -> 0 -> 207``), so it is fixed by an encoder **re-zero**: +relocate the seam into the arc the joint physically cannot reach, and every +reachable tick is then on one side of it — linear again. ``wrist_roll`` cannot +take that path. Exploration found **no wall anywhere** in its travel — measured free range ``[21, 4073]`` — meaning it rotates freely all the way round. A re-zero only *relocates* the seam; it can never *evict* it, because eviction requires an arc the joint cannot @@ -627,10 +628,63 @@ def _require_dead_arc_contains_seam(table: Mapping[str, SoftLimit]) -> None: #: the other, and pinned equal by a cross-module test. MAX_ENCODER_OFFSET: int = 2047 +#: The encoder offset a factory-fresh STS3215 actually ships holding: **85**. +#: **Not 0** — which is what every source, and this codebase's first re-zero, +#: assumed. +#: +#: Measured on the follower on 2026-07-12, before anything had been written to +#: any servo's addr 31: **all six joints read ``Ofs = 85``.** Uniform across six +#: independently-manufactured servos, so it is a vendor default baked into the +#: firmware image — not a per-servo calibration, and not something a user did. +#: Confirmed to be a genuine correction rather than a stale register by a +#: reversible probe: writing ``85 -> 185`` dropped the reported position by +#: exactly 100, and zeroing ``85 -> 0`` raised it by exactly 85. That is +#: ``Present = Actual - Ofs``, exactly as :func:`seam_tick` assumes. +#: +#: Two consequences, and both are load-bearing: +#: +#: * **A "factory" servo's reported positions are NOT raw ticks.** They are +#: already shifted by −85. Anything that treats a reported tick as a raw tick +#: is wrong by 85 on a brand-new arm — which is the default state of every +#: SO-101 (see the ``REZERO_ARCS`` note below; the arc this table shipped with +#: was measured in exactly that shifted frame and then used as if it were raw). +#: * **The factory seam is at raw 85, which is INSIDE ``elbow_flex``'s travel.** +#: Its travel includes the raw band ``[0, 207]``, so a factory-fresh +#: ``elbow_flex`` carries its encoder discontinuity right in the middle of +#: where it works. That is issue #35, and 85 is where it actually lives. +#: +#: Kept here rather than in ``rezero.py`` because it is a *fact about the +#: hardware*, in the module that holds facts about the hardware. Nothing in the +#: code branches on it — :func:`rezero_arc` and :func:`plan_rezero` read the +#: servo's live offset and convert, rather than assuming any particular starting +#: value, and that is the whole point of the fix. It is documented so that the +#: number in front of an operator ("offset: 85") has a name. +FACTORY_ENCODER_OFFSET: int = 85 + + +def seam_tick(offset: int) -> int: + """The RAW encoder tick at which a servo holding *offset* carries its seam. + + The inverse of :func:`_offset_for_seam_at`, and the single piece of + arithmetic the whole re-zero turns on. With + ``Present = (Actual - Ofs) mod 4096`` the reported value rolls ``4095 -> 0`` + exactly where ``Actual == Ofs``, so the seam's raw tick simply **is** the + offset — reduced modulo 4096, because the register is SIGNED (it is + sign-magnitude on bit 11, range ``[-2047, +2047]``) while the encoder is + not. + + That reduction is not a formality. A servo holding ``-1096`` carries its seam + at raw **3000**, not at "-1096": those are the same residue and only one of + them is a tick. Comparing the signed number straight against a raw arc would + place the seam a whole turn away from where it physically is, and every + "is the seam evicted?" answer downstream would be wrong. + """ + return offset % ENCODER_TICKS + @dataclass(frozen=True) class UnreachableArc: - """The contiguous arc of raw encoder ticks a joint physically CANNOT reach. + """The contiguous arc of **RAW** encoder ticks a joint physically CANNOT reach. The mirror image of a :class:`SoftLimit`, and the thing that makes an encoder re-zero possible at all. A joint with real mechanical walls cannot @@ -642,17 +696,42 @@ class UnreachableArc: which turns freely through the whole circle) cannot be helped this way at all — there is nowhere to put the seam. See :func:`rezero_refusal`. - Expressed as the OPEN interval ``(low, high)`` in **raw** encoder ticks: - both endpoints are positions the joint *can* reach (they are its hard - walls, or the last positions measured before them), and everything strictly - between them is unreachable. Raw, not corrected — this arc describes the - magnet on the shaft, and it is the frame the offset is computed *in*, before - any offset exists. + **RAW TICKS. NOT REPORTED TICKS. THIS IS THE WHOLE POINT.** + --------------------------------------------------------- + Say it loudly because getting it wrong is silent, and it *was* got wrong + (fixed 2026-07-12; the arc this table originally shipped with — + ``(126, 2020)`` — was measured on a servo already holding the factory + :data:`FACTORY_ENCODER_OFFSET` of 85, i.e. in the REPORTED frame, and then + used as if it were raw). An arc is a claim about **the magnet on the shaft**: + which physical angles the joint's own mechanical walls forbid. It is a + property of the *joint*, and it is **independent of whatever number happens + to be sitting in the offset register** — writing an offset changes what the + servo *reports*, and moves nothing at all. Re-zero the joint twice, or + ten times, and the arc is the same arc. + + The offset register operates in this same raw frame (the seam lands where + ``Actual == Ofs``, see :func:`seam_tick`), which is exactly why the arc must + be raw: an arc expressed in reported ticks would be off by the pre-existing + offset, and the target computed from it would place the seam by that much + error. It happens to be survivable when the arc is wide and the pre-existing + offset is small — the ``(126, 2020)`` bug shipped a target that still landed + inside the true arc, by luck and with margin to spare — and it is fatal when + either of those stops being true. + + So: **anything read off a live servo is REPORTED and must be converted** + (``raw = (reported + offset) mod 4096``, + :func:`arm101.hardware.rezero.raw_from_reported`) before it is compared + against an arc. There is no frame in which both readings are the same except + the one where the register happens to hold 0 — and no servo ships that way. + + Expressed as the OPEN interval ``(low, high)``: both endpoints are positions + the joint *can* reach (they are its hard walls, or the last positions + measured before them), and everything strictly between them is unreachable. Attributes ---------- low, high : int - The reachable ticks bounding the unreachable arc, ``low < high``. + The reachable RAW ticks bounding the unreachable arc, ``low < high``. Raises ------ @@ -680,34 +759,77 @@ def width(self) -> int: def travel_ticks(self) -> int: """The joint's physical travel: everything the arc does NOT exclude. - ``ENCODER_TICKS - width``. For ``elbow_flex`` this is 2202 ticks — a - **lower bound**, because its far wall has never been measured (``arm - explore`` cannot see across the seam, which is the whole problem). The - ``--verify`` sweep measures it for the first time. + ``ENCODER_TICKS - width``. For ``elbow_flex`` this is **2196 ticks**, and + as of 2026-07-12 that is a *measurement*, not a bound: the far wall was + finally seen by a torque-off hand sweep with the seam evicted (the one + instrument that can cross where ``arm explore`` could not). """ return ENCODER_TICKS - self.width @property def midpoint(self) -> int: - """The tick dead-centre of the arc — where the seam gets the most clearance. - - Any tick strictly inside the arc would work; the midpoint maximises the - margin on both sides, so a slightly-wrong arc boundary (and - ``elbow_flex``'s far wall IS only a bound, not a measurement) still - leaves the seam safely out of reach. + """The raw tick dead-centre of the arc — where the seam gets the most clearance. + + The *target*, not the *goal*. The goal is :meth:`evicts`: the seam must be + somewhere the joint can never reach. Any tick strictly inside the arc + achieves that, and the midpoint is merely the one that maximises the + margin on both sides — so an arc endpoint that is off by a few ticks + still leaves the seam safely out of reach. It is what a re-zero writes + when it writes anything at all; it is emphatically NOT a number a servo + has to be holding for the joint to be considered fixed. """ return (self.low + self.high) // 2 def contains(self, tick: int) -> bool: - """``True`` iff *tick* is strictly inside the arc — i.e. unreachable. + """``True`` iff RAW *tick* is strictly inside the arc — i.e. unreachable. Strict, because the endpoints are exactly the ticks the joint CAN reach. A joint reporting a raw position for which this is ``True`` is reporting a position it should be physically incapable of holding — the arc is wrong, or the servo is not the one we think it is. + + Takes a **raw** tick, like everything else on this class. Hand it a + position read straight off a servo and you have compared the wrong frame: + convert first (:func:`arm101.hardware.rezero.raw_from_reported`). """ return self.low < tick < self.high + def evicts(self, offset: int) -> bool: + """``True`` iff a servo holding *offset* has its seam OUT of the joint's travel. + + The actual goal of a re-zero, stated once and in one place: not "the + register holds a particular number" but "the discontinuity is somewhere + the joint can never go". An offset evicts the seam iff its seam tick + (:func:`seam_tick` — the offset reduced modulo 4096, because the register + is signed and the encoder is not) lands strictly inside this arc. + + Two things follow, and they are why this method exists rather than an + ``== midpoint`` comparison scattered at the call sites: + + * **A servo already holding a *different* evicting offset is already + fixed.** Rewriting its EEPROM to centre the seam more prettily would + buy nothing physical and spend a write on a finite-write part. (Our + follower holds ``1073``, from the first — frame-confused — re-zero; its + seam sits at raw 1073, comfortably inside ``(207, 2107)``, and a hand + sweep proved the travel continuous. It is done. Leave it alone.) + * **A servo holding the factory** :data:`FACTORY_ENCODER_OFFSET` **is + not.** Its seam sits at raw 85 — inside ``elbow_flex``'s ``[0, 207]`` + reachable band, which is precisely issue #35. + """ + return self.contains(seam_tick(offset)) + + @property + def offset(self) -> int: + """The signed register value that puts the seam at this arc's :attr:`midpoint`. + + The single source of the "what would a fresh re-zero write?" answer: + :func:`rezero_offset` is this, and so is every report that quotes a + target. Derived, so an arc correction propagates to the offset without + anybody remembering to follow it — which is what let the 2026-07-12 + re-measurement move the target from 1073 to 1157 by editing one tuple. + """ + return _offset_for_seam_at(self.midpoint) + def _offset_for_seam_at(tick: int) -> int: """Return the SIGNED offset ``H`` that places the encoder seam at raw *tick*. @@ -728,6 +850,8 @@ def _offset_for_seam_at(tick: int) -> int: #: Per-joint unreachable arcs — the joints an encoder re-zero can actually fix. +#: **RAW ticks** (:class:`UnreachableArc`), which is the correction this table +#: carries: the numbers it shipped with were not. #: #: ``elbow_flex`` is the only entry, and the only joint that needs one. Its #: encoder WRAPS inside its physical travel (issue #35): driven far enough it @@ -738,48 +862,80 @@ def _offset_for_seam_at(tick: int) -> int: #: sorting its two measured endpoints into a ``[min, max]`` pair yields exactly #: the arc it CANNOT reach. #: -#: The arc below is measured, not assumed (``docs/spikes/sts3215-offset-register.md`` -#: §3, from ``arm-explore-follower.map.json`` and the t9 run-log): +#: Measured on hardware, follower ``/dev/ttyACM1``, **2026-07-12** — a torque-off +#: hand sweep of the joint's *entire* travel with the seam already evicted +#: (``Ofs = 1073``), which is the first instrument that could ever see across the +#: wrap. It reported a **monotonic, discontinuity-free** run of **2196 ticks**, +#: spanning **1034 .. 3230 in that corrected frame**. Converting back to the raw +#: frame the shaft actually lives in (``raw = (reported + 1073) mod 4096``): +#: +#: * near wall : reported 1034 -> raw **2107** +#: * far wall : reported 3230 -> raw **207** (``3230 + 1073 = 4303 mod 4096``) +#: * so travel runs ``2107 -> 4095 -> |raw seam| -> 0 -> 207``, i.e. the raw set +#: ``[2107, 4095] ∪ [0, 207]`` — **2196 ticks**, and it WRAPS, which is exactly +#: the fact a ``[min, max]`` pair cannot express and this arc exists to state. +#: * the complement — the arc it cannot reach — is therefore **(207, 2107)**: +#: **1900 ticks**, and *this is a measurement*, not the upper bound the old +#: entry was. #: -#: * Hard wall driving *decreasing*: raw **2020**. -#: * Driven *increasing* it crossed the seam and read back ~1; it now rests at -#: raw **~126**, i.e. PAST its wrap. -#: * So its travel runs ``2020 -> 4095 -> |seam| -> 0 -> 126``: 2202 ticks, -#: and the arc ``(126, 2020)`` — 1894 ticks — is unreachable. +#: **The far wall had never been measured before this run** (nothing could see +#: across the seam — that was the whole problem), so the old entry was built from +#: the near wall plus the rest position and was a bound in both directions. #: -#: The far wall has never been measured (nothing could see across the seam), so -#: 2202 is a LOWER bound on travel and 1894 an UPPER bound on the arc. That does -#: not threaten the fix — any travel under a full turn leaves somewhere to put -#: the seam, and 2202 is nowhere near 4096 — but the arc should be re-derived, -#: and the offset with it, once ``arm rezero elbow_flex --verify`` finally -#: measures the far wall. +#: **Why the old ``(126, 2020)`` was WRONG, not merely loose** (this is the bug +#: fixed here, and it is a reasoning bug, not an arithmetic one): 126 and 2020 +#: were read off a servo that was *already holding the factory +#: :data:`FACTORY_ENCODER_OFFSET` of 85*. They are **REPORTED** ticks. The offset +#: register works in RAW ticks (:func:`seam_tick`), so the target derived from +#: them — midpoint 1073 — was computed in the wrong frame and was off by the +#: pre-existing offset. **It worked anyway, by luck:** raw 1073 lands inside the +#: true arc ``(207, 2107)`` with ~866/1034 ticks of margin to spare. A narrower +#: arc, or a larger factory offset, and that same "correct" read-back would have +#: parked the seam back inside the joint's travel with nothing to show for it. #: -#: Midpoint 1073, which is where the seam lands: 947 ticks of clearance on each -#: side. Cross-check: LeRobot's own rule (park mid-travel, write ``H = pos - -#: 2047``) gives ``3121 - 2047 = 1074`` — one tick away, by a completely -#: different route. +#: Midpoint **1157** — where a *fresh* re-zero puts the seam: **950 ticks of +#: clearance on each side**, the most the arc allows. Note that the arm this was +#: measured on is NOT re-written to 1157: it holds 1073, which +#: :meth:`UnreachableArc.evicts` — the seam is out of the travel, the axis is +#: linear, and the job is done. See :func:`rezero_offset`. REZERO_ARCS: dict[str, UnreachableArc] = { - "elbow_flex": UnreachableArc(low=126, high=2020), + # RAW ticks. Hardware sweep, follower, 2026-07-12: travel 1034..3230 reported + # at Ofs=1073 (span 2196, monotonic, 0 discontinuities) -> raw [2107, 4095] ∪ + # [0, 207] -> unreachable (207, 2107). The far wall, measured for the first + # time. The previous (126, 2020) were REPORTED-frame ticks read at the factory + # Ofs=85 and used as if they were raw. + "elbow_flex": UnreachableArc(low=207, high=2107), } def _require_evictable_seam(table: Mapping[str, UnreachableArc]) -> None: """Raise ``ValueError`` if any arc in *table* cannot actually take the seam. - Two ways a table entry can be nonsense, both caught at import time — for + Three ways a table entry can be nonsense, all caught at import time — for every caller and every test — rather than discovered halfway through an EEPROM write on a physical servo: - 1. **The offset is unrepresentable.** The register holds ``[-2047, +2047]`` - (:data:`MAX_ENCODER_OFFSET`); the one seam placement it cannot express is - raw 2048. An arc whose midpoint lands there needs a human, not a rounding - rule. - 2. **The arc does not contain its own seam.** Vacuously true for the + 1. **The arc does not contain its own seam.** Vacuously true for the midpoint of a well-ordered open interval — *unless* the arc is only one tick wide (``high == low + 1``), in which case there is no tick strictly inside it and the "seam goes here" claim is empty. A one-tick arc means a joint whose travel is 4095 of 4096 ticks: essentially ``wrist_roll``, and a re-zero is the wrong tool (see :func:`rezero_refusal`). + 2. **The offset is unrepresentable.** The register holds ``[-2047, +2047]`` + (:data:`MAX_ENCODER_OFFSET`); the one seam placement it cannot express is + raw 2048. An arc whose midpoint lands there needs a human, not a rounding + rule. + 3. **The signed offset does not land back on the raw tick it came from** — + i.e. the RAW -> SIGNED -> RAW round-trip + (:func:`_offset_for_seam_at` then :meth:`UnreachableArc.evicts`, which + goes through :func:`seam_tick`) does not close. This is the check the + frame bug of 2026-07-12 would have wanted: the offset written to the + register and the arc it was derived from must be talking about the same + tick. It cannot fail while ``_offset_for_seam_at`` and ``seam_tick`` stay + true inverses — which is exactly why it is worth pinning, because they are + the two halves of the arithmetic that got confused, and a future edit to + either that quietly breaks the correspondence would otherwise surface as a + seam written into a joint's live travel. This mirrors :func:`_require_dead_arc_contains_seam` for :data:`SOFT_LIMITS`, and for the same reason: the guarantee is *enforced*, not merely documented, @@ -804,6 +960,15 @@ def _require_evictable_seam(table: Mapping[str, UnreachableArc]) -> None: "seam placement the sign-magnitude encoding cannot express; pick another " "tick inside the arc." ) + if not arc.evicts(offset): + raise ValueError( # pragma: no cover - unreachable while the inverses hold + f"Unreachable arc for {joint!r} is ({arc.low}, {arc.high}), but the offset " + f"{offset} derived from its midpoint {seam} puts the seam at raw tick " + f"{seam_tick(offset)} — which is NOT inside the arc. The raw<->signed " + "round-trip is broken: _offset_for_seam_at and seam_tick are no longer " + "inverses, so the offset written to the register and the arc it came from " + "are describing different ticks." + ) _require_evictable_seam(REZERO_ARCS) @@ -861,17 +1026,28 @@ def rezero_arc(joint: str) -> Optional[UnreachableArc]: def rezero_offset(joint: str) -> Optional[int]: - """Return the signed encoder offset that evicts *joint*'s seam, or ``None``. + """Return the signed encoder offset a re-zero would WRITE to *joint*, or ``None``. ``None`` means the joint is not re-zeroable — call :func:`rezero_refusal` for the reason, which is never "no reason". The offset is DERIVED from :data:`REZERO_ARCS`, never typed: it is the signed form (:func:`_offset_for_seam_at`) of the arc's midpoint. So - correcting the arc — which will happen the first time ``--verify`` measures - ``elbow_flex``'s far wall — automatically corrects the offset, and the two - cannot drift apart. For ``elbow_flex`` today: arc ``(126, 2020)``, midpoint - 1073, offset **+1073**. + correcting the arc corrects the offset with it and the two cannot drift + apart — which is exactly what happened on 2026-07-12, when the first sweep of + ``elbow_flex``'s far wall replaced a reported-frame guess with a raw-frame + measurement, and this function's answer moved from 1073 to 1157 without a + line of it changing. For ``elbow_flex`` today: arc ``(207, 2107)``, midpoint + 1157, offset **+1157** — 950 ticks of clearance on each side. + + **This is a target, not a requirement.** A servo does not have to hold *this* + number to be correctly re-zeroed; it has to hold *an offset that evicts the + seam* (:meth:`UnreachableArc.evicts` — any tick strictly inside the arc). Our + own follower holds ``1073`` and is fine. Code that asks "is this joint + re-zeroed?" must ask ``arc.evicts(current)``, never ``current == + rezero_offset(joint)``: the second question is a different, stricter, and + physically meaningless one, and answering it instead would rewrite a working + calibration to move a seam from one unreachable tick to another. Raises ------ @@ -881,7 +1057,7 @@ def rezero_offset(joint: str) -> Optional[int]: arc = rezero_arc(joint) # validates *joint* if arc is None: return None - return _offset_for_seam_at(arc.midpoint) + return arc.offset def rezero_refusal(joint: str) -> Optional[str]: diff --git a/arm101/hardware/rezero.py b/arm101/hardware/rezero.py index 6fd7b4e..7617ac3 100644 --- a/arm101/hardware/rezero.py +++ b/arm101/hardware/rezero.py @@ -8,14 +8,53 @@ report similar ticks, the joint's two measured endpoints sort into a ``[min, max]`` pair that describes exactly the arc it CANNOT reach, and every position comparison in this codebase — ``gentle_move``'s arrival check, ``clamp_goal``, -the reachability map's ranges — is silently wrong for it. It currently rests at -raw ~126, i.e. **past** its wrap. +the reachability map's ranges — is silently wrong for it. It rests at raw ~126, +i.e. **past** its wrap. The fix is to shift the encoder's zero (``Ofs``/``Homing_Offset``, EEPROM addr 31) so the seam falls inside the arc the joint physically cannot reach -(:class:`~arm101.hardware.arm_spec.UnreachableArc`). Then every tick the joint -can actually reach lies on one side of the seam, and the linear-axis assumption -the whole codebase already makes becomes TRUE rather than merely assumed. +(:class:`~arm101.hardware.arm_spec.UnreachableArc`, raw ``(207, 2107)``). Then +every tick the joint can actually reach lies on one side of the seam, and the +linear-axis assumption the whole codebase already makes becomes TRUE rather than +merely assumed. + +Two frames, and everything turns on keeping them apart +------------------------------------------------------ +A servo reports ``Present = (Actual - Ofs) mod 4096``. So there are two tick +frames in play and they are only the same when the register holds 0 — **which no +servo ships doing**: the factory default is +:data:`~arm101.hardware.arm_spec.FACTORY_ENCODER_OFFSET` = **85**, measured +uniform across all six joints of the follower on 2026-07-12. + +* **RAW** — the magnet on the shaft. The joint's mechanical walls, its + unreachable arc, and the seam (which lands where ``Actual == Ofs``) all live + here. This frame does not move when you write the offset register; nothing + about the joint's physics does. +* **REPORTED** — what comes back over the wire, and therefore *everything* + :meth:`~arm101.hardware.bus.MotorBus.read_position` hands you. Shifted by + whatever offset the servo currently holds. + +**The arc is RAW. Every live read is REPORTED.** :func:`raw_from_reported` is the +only bridge, and there is no path through this module that compares a live +reading against the arc without crossing it. That sentence is not decoration: the +first version of this code compared them directly, on a table whose numbers had +themselves been measured in the reported frame at ``Ofs = 85``, and it computed +its target a whole factory-offset away from where it thought. It landed inside +the true arc anyway — by luck and by margin — which is the most dangerous way for +a frame bug to behave, because every read-back looked right. + +The goal is a place, not a number +--------------------------------- +"Re-zeroed" means **the seam is outside the joint's travel** +(:meth:`~arm101.hardware.arm_spec.UnreachableArc.evicts`). It does *not* mean the +register holds :func:`~arm101.hardware.arm_spec.rezero_offset`'s exact answer. +Any offset whose seam tick lands strictly inside the arc has done the job; the +midpoint is simply the one with the most margin, and is what a *fresh* re-zero +writes. A joint already holding a different evicting offset — our follower holds +``1073``, from the frame-confused first pass, and a hand sweep proved its travel +continuous — is **already fixed**, and :func:`plan_rezero` reports it as a no-op +rather than spending an EEPROM write to slide a seam from one unreachable tick to +another. The bootstrap problem — why nothing here commands motion -------------------------------------------------------- @@ -24,12 +63,12 @@ The obvious procedure — "drive the joint to mid-travel, then write the offset that centres it" — is exactly the thing that must not happen. ``elbow_flex`` -rests at raw ~126, on the far side of its wrap. A goal of, say, 3121 (its -mid-travel) looks like a modest move in tick-space and is in fact a rotation -the *long way round*: the servo would drive from 126 down through 0, across the -whole 1894-tick arc it cannot reach, and into a wall. The commanded number is -sane; the physical consequence is not; and the discrepancy is precisely the -non-linearity this write exists to remove. So: +rests at raw ~126, on the far side of its wrap. A goal at its mid-travel looks +like a modest move in tick-space and is in fact a rotation the *long way round*: +the servo would drive from 126 down through 0, across the whole 1900-tick arc it +cannot reach, and into a wall. The commanded number is sane; the physical +consequence is not; and the discrepancy is precisely the non-linearity this write +exists to remove. So: * :func:`apply_rezero` **writes no goal position, ever** — the wire surface is torque-off, unlock, addr 31, re-lock, and nothing else. It reads where the @@ -44,8 +83,8 @@ off — a joint must not be *holding* when its own frame of reference changes underneath it. -The unproven assumption — and how ``--verify`` settles it ---------------------------------------------------------- +The assumption — SETTLED on hardware, and still tested both ways +---------------------------------------------------------------- Everything above rests on one undocumented bit of firmware semantics (``docs/spikes/sts3215-offset-register.md`` §4):: @@ -54,10 +93,22 @@ Under the second reading the offset merely *relabels* positions: the discontinuity stays pinned to the physical angle where the magnet rolls over, -and the re-zero achieves nothing at all. Every source and LeRobot's shipped -SO-101 calibration imply the first, but no primary Feetech source states the -formula — so :class:`~arm101.hardware.bus.FakeBus` models BOTH -(``offset_wraps=True`` / ``False``) and this module is tested against both. +and the re-zero achieves nothing at all. No primary Feetech source states the +formula, so the spike shipped this as an open caveat. + +**It is the first reading. Proved on the follower, 2026-07-12, by the only +instrument that could: a torque-off hand sweep.** With ``Ofs = 0`` the sweep came +back ``monotonic: False, discontinuities: 1`` — the seam, sitting in the travel. +With ``Ofs = 1073`` (inside the unreachable arc) the same sweep came back +``monotonic: True, discontinuities: 0`` across all 2196 ticks of travel. **The +correction IS reduced modulo 4096; the seam RELOCATES.** The re-zero works. + +The both-worlds machinery stays anyway, and is not vestigial: +:class:`~arm101.hardware.bus.FakeBus` still models both readings +(``offset_wraps=True`` / ``False``) and this module is still tested against both. +The proof is one arm, one firmware revision; the ``seam-not-evicted`` verdict is +what would catch a servo that does not behave like it — and a verification that +*cannot fail* is not a verification. **Reading the offset back only proves it was APPLIED. It does not prove the seam MOVED.** Only a sweep does — which is why :func:`sweep` exists and why it @@ -95,17 +146,21 @@ #: report jumps 4095 -> 0, a delta of ~4095. #: * Offset written, firmware does a plain signed subtraction, and #: :meth:`~arm101.hardware.bus.FeetechBus.read_position`'s ``& 0x0FFF`` folds -#: the negative result back into range: the jump is ~1949 ticks for -#: ``H = 1073``. **This is the smallest discontinuity we can be shown**, and it -#: is why the threshold is not the tempting 2048. +#: the negative result back into range: the report falls from ``4095 - H`` to +#: ``H``, a jump of ``4095 - 2H`` — **~1781 ticks** at the current ``H = 1157``. +#: **This is the smallest discontinuity we can be shown**, and it is why the +#: threshold is not the tempting 2048. (Note it *shrinks* as the seam is +#: centred: an arc whose midpoint approached 2048 would shrink it toward zero. +#: ``elbow_flex``'s does not come close — but a future joint's might, and this +#: is where that would first bite.) #: * The same, unmasked (what :class:`~arm101.hardware.bus.FakeBus` reports with #: ``offset_wraps=False``): the position goes NEGATIVE, a delta of ~4095, and #: :attr:`SweepReport.out_of_range` catches it independently anyway. #: -#: Against a human hand: the whole 2202-tick travel moved in a brisk 2 s, polled +#: Against a human hand: the whole 2196-tick travel moved in a brisk 2 s, polled #: every 50 ms, is ~55 ticks per sample. Even a yank is well under 500. The gap #: between "fastest plausible hand" and "smallest possible seam crossing" is -#: nearly 4x, and 500 sits in the middle of it. +#: over 3x, and 500 sits in the middle of it. DISCONTINUITY_TICKS: int = 500 #: A direction reversal smaller than this is the operator's hand, not a signal. @@ -138,8 +193,8 @@ #: one actually observed. The joint is limp while this is measured, so gravity, #: backlash and a nudged cable all move it a little between the pre-write read #: and the post-write read. Generous on purpose: this check exists to catch an -#: offset that did nothing (delta ~1073) or went the wrong way, not to police -#: encoder jitter. +#: offset that did nothing (a delta of the whole offset shift, ~1000 ticks) or one +#: that went the wrong way, not to police encoder jitter. POSITION_TOLERANCE: int = 30 #: Verdicts a :class:`SweepReport` can return. Deliberately four, not two — @@ -180,9 +235,15 @@ def require_rezeroable(joint: str) -> "tuple[int, arm_spec.UnreachableArc]": Returns ------- tuple[int, UnreachableArc] - The signed encoder offset that evicts *joint*'s seam (``+1073`` for - ``elbow_flex``, the only re-zeroable joint on this arm), and the - unreachable arc it was derived from. + The signed encoder offset a *fresh* re-zero would write — the arc's + midpoint, ``+1157`` for ``elbow_flex``, the only re-zeroable joint on + this arm — and the RAW-tick unreachable arc it was derived from. + + The arc is the more important half. The offset is one of ~1899 that + would do (any tick strictly inside the arc evicts the seam); the arc is + the thing that says which. Callers deciding "is this servo already + re-zeroed?" must ask ``arc.evicts(current_offset)``, never + ``current_offset == offset``. Raises ------ @@ -236,31 +297,51 @@ class RezeroPlan: joint, motor: Which joint, and the servo id carrying it. current_offset: - The signed offset the servo holds RIGHT NOW (0 on a factory servo). + The signed offset the servo holds RIGHT NOW. **Not 0 on a factory + servo** — it is 85 + (:data:`~arm101.hardware.arm_spec.FACTORY_ENCODER_OFFSET`), on every + joint of a fresh SO-101. Read, never assumed. + current_seam_tick: + Where *current_offset* puts the seam, in RAW ticks: ``current mod + 4096``. This is the number that decides whether the joint is already + fixed — inside the arc, and the seam can never be crossed. On a factory + servo it is 85, which is squarely inside ``elbow_flex``'s reachable + ``[0, 207]`` band, i.e. issue #35 exactly. target_offset: - The signed offset about to be written — derived from the joint's - unreachable arc, never typed. + The signed offset that will be in force once this plan is carried out. + On the write path, the arc's midpoint (derived, never typed). On the + no-op path, *current_offset* itself — because nothing is written, and a + plan that named a target it was not going to write would be lying about + what is about to happen. reported_position: What the servo reports today, i.e. already corrected by - *current_offset*. + *current_offset*. **A reported tick, not a raw one.** raw_position: Where the shaft physically is, in the encoder's own frame: - ``(reported + current_offset) mod 4096``. On a factory servo - (*current_offset* == 0) this is an identity and carries no assumption - at all — which is the case the whole procedure is designed around. + ``(reported + current_offset) mod 4096`` (:func:`raw_from_reported`). + The ONLY value that may be compared against the arc. predicted_position: What the servo will report once the write lands, IF the corrected position is reduced modulo 4096: ``(raw - target) mod 4096``. This is - the first, cheapest test of the open question — see - :func:`describe_shift`. + the first, cheapest test of the firmware semantics — see + :func:`describe_shift`. On the no-op path this is just + *reported_position* again, which is precisely right: nothing is going to + change. already_applied: - The servo already holds *target_offset*. The write is a no-op; say so - rather than performing it again. + **The seam is already outside the joint's travel** — the offset the + servo holds evicts it (:meth:`~arm101.hardware.arm_spec.UnreachableArc.evicts`). + The joint is fixed; the write is a no-op; say so rather than performing + it. Note this is a claim about *where the seam is*, NOT about the offset + matching a particular number: an arm carrying any of the ~1899 offsets + whose seam lands inside the arc is done, and re-writing its EEPROM to + centre the seam more prettily would spend a write on a finite-write part + and change nothing a joint can feel. """ joint: str motor: int current_offset: int + current_seam_tick: int target_offset: int reported_position: int raw_position: int @@ -273,6 +354,7 @@ def as_dict(self) -> dict[str, object]: "joint": self.joint, "motor": self.motor, "current_offset": self.current_offset, + "current_seam_tick": self.current_seam_tick, "target_offset": self.target_offset, "reported_position": self.reported_position, "raw_position": self.raw_position, @@ -282,15 +364,26 @@ def as_dict(self) -> dict[str, object]: def raw_from_reported(reported: int, offset: int) -> int: - """Recover the shaft's raw encoder count from what the servo REPORTS. + """Recover the shaft's RAW encoder count from what the servo REPORTS. ``Actual = (Present + Ofs) mod 4096`` — the inverse of the correction the - servo applies. With the factory offset of 0 this is the identity, and the - only case that matters for a first re-zero needs no inverse at all; the - function exists so that a servo which has ALREADY been re-zeroed can still - be located in the raw frame the arc table is written in (e.g. to re-run the - reachability checks, or to detect that the joint is somewhere it should not - physically be able to be). + servo applies (``Present = Actual - Ofs``, confirmed on hardware 2026-07-12 + by a reversible probe: writing ``Ofs 85 -> 185`` dropped the reported + position by exactly 100, and ``85 -> 0`` raised it by exactly 85). + + **The one bridge between the two frames this module lives in**, and it is on + every path, not just the exotic ones. It is tempting to think of it as a + special case for an already-re-zeroed servo — that is what an earlier version + of this module thought, treating the factory state as "offset 0, so reported + IS raw". The factory state is ``Ofs = 85`` + (:data:`~arm101.hardware.arm_spec.FACTORY_ENCODER_OFFSET`), so that identity + never held, on any servo, ever; the conversion has to happen every time. + + The ``mod 4096`` is load-bearing, not defensive. ``reported + offset`` + genuinely runs past 4096 for positions near the top of the travel (a joint + reporting 4000 on a servo holding +200 is physically at raw 104, not 4200 — + there is no tick 4200), and it genuinely runs below 0 for a negative offset. + Both fold back onto the circle, because the encoder is a circle. """ return (reported + offset) % arm_spec.ENCODER_TICKS @@ -298,85 +391,92 @@ def raw_from_reported(reported: int, offset: int) -> int: def plan_rezero(bus: "MotorBus", motor: int, joint: str) -> RezeroPlan: """Read the joint's live state and work out exactly what the re-zero will write. - **Reads only.** No torque write, no goal, no EEPROM. Two guards make this - more than a formatting exercise, and both refuse rather than guess: - - *An unknown frame.* If the servo already holds an offset that is neither the - factory ``0`` nor the target, we do not know what frame its reported - positions are in, so we cannot honestly convert them to raw ticks and cannot - honestly check anything below. Writing a new offset on top of an unknown one - would bury the problem instead of surfacing it. - - *A physically impossible position.* If the joint's raw position lands - strictly inside the arc it supposedly cannot reach, then either the arc is - wrong or this servo is not the joint we think it is. Either way the offset + **Reads only.** No torque write, no goal, no EEPROM. + + *Read the frame; do not assume it.* The servo is asked what offset it is + holding, and every position it reports is converted into the RAW frame the + arc is written in (:func:`raw_from_reported`) before anything is compared + against anything. There is no offset this refuses to start from: whatever the + register holds — the factory 85, a 0 somebody zeroed, a previous re-zero's + 1073, a negative — it is a number, it is readable, and ``raw = (reported + + offset) mod 4096`` converts out of it exactly. + + (It did once refuse. The guard read *"already holds an encoder offset of 85, + which is neither the factory 0 nor this joint's computed 1073 … cannot + interpret"*, and it was the right guard for a tool that could not convert + frames. This one can. The guard's real-world effect was to block the verb + outright on a factory-fresh servo — which is the default state of every + SO-101, i.e. the exact case it most needed to work on.) + + *The goal is a place, not a number.* If the offset the servo already holds + puts the seam strictly inside the unreachable arc + (:meth:`~arm101.hardware.arm_spec.UnreachableArc.evicts`), the seam is + already out of the joint's travel: the axis is linear, issue #35 is fixed for + this joint, and there is nothing to do. That is reported as + :attr:`RezeroPlan.already_applied` and **nothing is written** — not even the + "correct" midpoint. Sliding a seam from one tick the joint cannot reach to + another tick the joint cannot reach is cosmetic; EEPROM writes are not free, + and a re-write would also make the log ambiguous about which run wrote the + calibration that is actually in force. + + *A physically impossible position still refuses.* If the joint's RAW position + lands strictly inside the arc it supposedly cannot reach, then either the arc + is wrong or this servo is not the joint we think it is. Either way the offset about to be written is derived from a table that does not describe the hardware in front of us, and writing it would put the seam somewhere the joint CAN go — making issue #35 worse, not better, and doing it persistently - in EEPROM. + in EEPROM. This is the guard that would have caught the frame bug if the + numbers had been a little less lucky, and it is the one that stays. Raises ------ CliError(EXIT_USER_ERROR) If *joint* is not re-zeroable (see :func:`require_rezeroable`). CliError(EXIT_ENV_ERROR) - If the servo holds an unrecognised offset, or reports a raw position - inside its own unreachable arc. + If the servo reports a raw position inside its own unreachable arc. """ target, arc = require_rezeroable(joint) current = bus.read_offset(motor) reported = bus.read_position(motor) - - if current not in (0, target): - raise CliError( - code=EXIT_ENV_ERROR, - message=( - f"{joint} (motor {motor}) already holds an encoder offset of {current}, " - f"which is neither the factory 0 nor this joint's computed {target}. " - "Its reported positions are in a frame this tool did not set and cannot " - "interpret, so it will not write a new offset on top of it." - ), - remediation=( - "Inspect the live offset with 'arm101 arm read --json' (the 'offset' " - "column). A re-zero must start from a known frame: restore the servo's " - "offset register (EEPROM addr 31) to 0 — or to this joint's computed " - f"{target} — and re-run. If {current} was deliberate, the arc table in " - "arm101/hardware/arm_spec.py is what needs updating, not the servo." - ), - ) - raw = raw_from_reported(reported, current) if arc.contains(raw): raise CliError( code=EXIT_ENV_ERROR, message=( - f"{joint} (motor {motor}) reports raw encoder position {raw}, which is " - f"INSIDE the arc it is supposed to be physically unable to reach " - f"({arc.low}, {arc.high}). The joint cannot be where it says it is, so the " - "arc — and the offset derived from it — does not describe this hardware. " - "Refusing to write." + f"{joint} (motor {motor}) reports raw encoder position {raw} (it reports " + f"{reported} while holding an offset of {current}), which is INSIDE the arc " + f"it is supposed to be physically unable to reach ({arc.low}, {arc.high}). " + "The joint cannot be where it says it is, so the arc — and the offset " + "derived from it — does not describe this hardware. Refusing to write." ), remediation=( "Check that motor " f"{motor} really is {joint} ('arm101 arm read'), and that the arm is " "assembled as the arc was measured on. If the joint's travel has genuinely " - "changed, re-measure its walls and correct REZERO_ARCS in " + "changed, re-measure its walls IN THE RAW FRAME (raw = reported + offset, " + "mod 4096 — the arc is raw ticks, and a reported tick read off a servo " + "holding any offset at all is not one) and correct REZERO_ARCS in " "arm101/hardware/arm_spec.py — the offset is derived from that table, so " "correcting the table corrects the offset." ), ) + # The goal is "the seam is out of the travel", not "the register holds N". + evicted = arc.evicts(current) + effective_target = current if evicted else target + return RezeroPlan( joint=joint, motor=motor, current_offset=current, - target_offset=target, + current_seam_tick=arm_spec.seam_tick(current), + target_offset=effective_target, reported_position=reported, raw_position=raw, - predicted_position=(raw - target) % arm_spec.ENCODER_TICKS, - already_applied=current == target, + predicted_position=(raw - effective_target) % arm_spec.ENCODER_TICKS, + already_applied=evicted, ) @@ -485,18 +585,30 @@ class SweepReport: ---------- joint, motor: What was swept. - offset_in_force, expected_offset: - The offset the servo actually held during the sweep, and the one that - evicts this joint's seam. When they differ the sweep is a **baseline**, - not a proof — a perfectly useful thing to run (it SHOWS you the seam - before you fix it), but not the same claim. + offset_in_force: + The offset the servo actually held during the sweep — read off it, not + assumed. Whether that offset *evicts the seam* is :attr:`rezeroed`, and + when it does not the sweep is a **baseline**, not a proof: a perfectly + useful thing to run (it SHOWS you the seam before you fix it), but not + the same claim. + arc: + The joint's RAW unreachable arc. Carried rather than the two numbers + derived from it (:attr:`expected_offset`, :attr:`expected_travel`) + because the arc is what :attr:`rezeroed` actually has to ask — "does the + offset in force put the seam in HERE?" — and a report holding only a + target number could not answer that question at all. It could only ask + the wrong one. samples: Every position read, in order. The measurement record; every number below is derived from it. minimum, maximum, span: - The extent the joint was actually moved through. ``span`` is what - :attr:`conclusive` is judged on — and, incidentally, the first - measurement anyone has ever made of ``elbow_flex``'s far wall. + The extent the joint was actually moved through, in the REPORTED frame + the servo was speaking in at the time. ``span`` is what + :attr:`conclusive` is judged on — and it is what measured + ``elbow_flex``'s far wall for the first time on 2026-07-12 (2196 ticks, + reported 1034..3230 at ``Ofs = 1073``, which is where the raw arc + ``(207, 2107)`` in :data:`~arm101.hardware.arm_spec.REZERO_ARCS` comes + from). monotonic: The reported position never both rose and fell by more than :data:`REVERSAL_TOLERANCE`. **Descriptive, not decisive**: a human hand @@ -505,7 +617,7 @@ class SweepReport: either way. largest_jump, largest_jump_at: The biggest single-sample change, and the sample index it happened at. - A seam crossing is ~1949-4095 ticks; sensor noise and a human hand are + A seam crossing is ~1781-4095 ticks; sensor noise and a human hand are tens of ticks. The gap is not subtle. discontinuities: ``(index, before, after)`` for every jump at or above @@ -515,15 +627,12 @@ class SweepReport: Any sample outside ``[0, 4095]``. A position register cannot hold such a value — seeing one means the corrected position is an unwrapped signed subtraction, which independently proves the re-zero cannot work. - expected_travel: - The joint's travel in ticks, from its unreachable arc — the yardstick - :attr:`conclusive` measures :attr:`span` against. """ joint: str motor: int offset_in_force: int - expected_offset: int + arc: "arm_spec.UnreachableArc" samples: tuple[int, ...] minimum: int maximum: int @@ -532,17 +641,51 @@ class SweepReport: largest_jump_at: int discontinuities: tuple[tuple[int, int, int], ...] out_of_range: tuple[int, ...] - expected_travel: int @property def span(self) -> int: """Ticks between the extremes reached — how far the joint was actually moved.""" return self.maximum - self.minimum + @property + def expected_offset(self) -> int: + """The offset a FRESH re-zero of this joint would write — the arc's midpoint. + + Reported so an operator can see the canonical number, and NOT what + :attr:`rezeroed` is judged against. A servo holding some other offset that + also evicts the seam is re-zeroed, whatever this says. + """ + return self.arc.offset + + @property + def expected_travel(self) -> int: + """The joint's travel in ticks, from its arc — the yardstick for :attr:`span`.""" + return self.arc.travel_ticks + + @property + def seam_tick(self) -> int: + """Where the offset in force actually put the seam, in RAW ticks.""" + return arm_spec.seam_tick(self.offset_in_force) + @property def rezeroed(self) -> bool: - """The joint was carrying the seam-evicting offset while it was swept.""" - return self.offset_in_force == self.expected_offset + """The joint was swept while its seam sat OUTSIDE its own travel. + + Not "the register held the number we would have written". The sweep is a + physical measurement, and the question it is here to support is a physical + one: was the seam out of the way while the joint was walked from wall to + wall? Any offset whose seam tick lands strictly inside the arc satisfies + that (:meth:`~arm101.hardware.arm_spec.UnreachableArc.evicts`). + + Judging this by ``offset_in_force == expected_offset`` would call our own + follower — which holds ``1073``, whose seam sits at raw 1073, deep inside + ``(207, 2107)``, and whose hand sweep came back clean across all 2196 + ticks of travel — **not re-zeroed**, and downgrade the very run that + proved the fix works to ``inconclusive``. The report would be denying the + measurement in front of it on the grounds that a register held the wrong + integer. + """ + return self.arc.evicts(self.offset_in_force) @property def continuous(self) -> bool: @@ -639,8 +782,13 @@ def describe(self) -> str: headline, "", f"- joint : {self.joint} (motor {self.motor})", - f"- offset in force : {self.offset_in_force}" - f" (seam-evicting offset for this joint: {self.expected_offset})", + f"- offset in force : {self.offset_in_force} -> seam at raw tick" + f" {self.seam_tick}, which is" + f" {'OUT of the joint' if self.rezeroed else 'INSIDE the joint'}'s travel" + f" ({'inside' if self.rezeroed else 'NOT inside'} the unreachable arc" + f" ({self.arc.low}, {self.arc.high}))", + f"- a fresh re-zero : would write {self.expected_offset} (the arc's midpoint," + " maximum margin) — but ANY offset inside the arc does the job", f"- samples : {len(self.samples)}", f"- range reached : {self.minimum} .. {self.maximum} (span {self.span} ticks" f", expected travel ~{self.expected_travel})", @@ -656,12 +804,17 @@ def describe(self) -> str: " so the corrected position is an UNWRAPPED signed subtraction." ) if self.rezeroed and self.continuous and self.conclusive: + raw_low = raw_from_reported(self.minimum, self.offset_in_force) + raw_high = raw_from_reported(self.maximum, self.offset_in_force) lines += [ "", - f"Far wall measured for the first time: {self.joint}'s travel spans " - f"{self.span} ticks ({self.minimum} .. {self.maximum} in the corrected " - "frame). The arc table in arm101/hardware/arm_spec.py was built on a " - f"LOWER BOUND of {self.expected_travel}; this is the real number.", + f"Travel measured: {self.joint} spans {self.span} ticks " + f"({self.minimum} .. {self.maximum} in the corrected frame this sweep ran " + f"in, i.e. RAW {raw_low} .. {raw_high}), against the " + f"{self.expected_travel} ticks the arc table predicts. If those differ by " + "more than a hand's slop at the walls, the arc in " + "arm101/hardware/arm_spec.py is what to correct — and it is RAW ticks, so " + "convert (raw = reported + offset, mod 4096) before you touch it.", ] return "\n".join(lines) @@ -669,11 +822,13 @@ def _why_inconclusive(self) -> str: """Spell out which of the two ways this sweep failed to test anything.""" if not self.rezeroed: return ( - f" {self.joint} was NOT re-zeroed during it (offset in force: " - f"{self.offset_in_force}, expected {self.expected_offset}) and no " - "discontinuity was seen — so either the sweep never reached the seam, or " - "this joint does not wrap where we think it does. It does NOT show the " - "re-zero working, because no re-zero was in force." + f" {self.joint} was NOT re-zeroed during it — the offset in force " + f"({self.offset_in_force}) puts the seam at raw tick {self.seam_tick}, " + f"which is INSIDE the joint's travel, not in the arc " + f"({self.arc.low}, {self.arc.high}) it cannot reach — and no discontinuity " + "was seen. So either the sweep never reached the seam, or this joint does " + "not wrap where we think it does. It does NOT show the re-zero working, " + "because no re-zero was in force." ) return ( f" The joint moved only {self.span} ticks of its ~{self.expected_travel}-tick " @@ -688,6 +843,8 @@ def as_dict(self) -> dict[str, object]: "joint": self.joint, "motor": self.motor, "offset_in_force": self.offset_in_force, + "seam_tick": self.seam_tick, + "unreachable_arc": [self.arc.low, self.arc.high], "expected_offset": self.expected_offset, "rezeroed": self.rezeroed, "samples": len(self.samples), @@ -718,16 +875,21 @@ def analyse_sweep( joint: str, motor: int, offset_in_force: int, - expected_offset: int, - expected_travel: int, + arc: "arm_spec.UnreachableArc", ) -> SweepReport: """Turn a list of polled positions into a :class:`SweepReport`. Pure — no bus. Split out from :func:`sweep` so the *judgement* can be tested exhaustively against hand-written position sequences (a clean sweep, a 4095->0 wrap, the - ~1949-tick masked-signed jump, negative readings, a two-sample nothing) + ~1781-tick masked-signed jump, negative readings, a two-sample nothing) without a bus, a clock, or a servo anywhere near it. + Takes the joint's *arc*, not a target offset and a travel length, because + :attr:`SweepReport.rezeroed` asks a question only the arc can answer: was the + seam outside the joint's travel while this ran? Both derived numbers + (:attr:`~SweepReport.expected_offset`, :attr:`~SweepReport.expected_travel`) + still come out of the report — from the arc, so they cannot disagree with it. + Raises ------ CliError(EXIT_ENV_ERROR) @@ -768,7 +930,7 @@ def analyse_sweep( joint=joint, motor=motor, offset_in_force=offset_in_force, - expected_offset=expected_offset, + arc=arc, samples=samples, minimum=min(samples), maximum=max(samples), @@ -777,7 +939,6 @@ def analyse_sweep( largest_jump_at=largest_jump_at, discontinuities=discontinuities, out_of_range=out_of_range, - expected_travel=expected_travel, ) @@ -853,7 +1014,7 @@ def sweep( remediation="Increase the sweep duration so it collects at least two samples.", ) - expected_offset, arc = require_rezeroable(joint) + _target, arc = require_rezeroable(joint) # Limp FIRST. A joint the human is about to hand-move must not be holding — # and one that fought a wall on the way here may be latched in overload, in @@ -880,8 +1041,7 @@ def sweep( joint=joint, motor=motor, offset_in_force=offset_in_force, - expected_offset=expected_offset, - expected_travel=arc.travel_ticks, + arc=arc, ) diff --git a/docs/hardware-rezero-procedure.md b/docs/hardware-rezero-procedure.md index 51f429d..b952016 100644 --- a/docs/hardware-rezero-procedure.md +++ b/docs/hardware-rezero-procedure.md @@ -22,13 +22,23 @@ your hand, not the motor. | Joint | `elbow_flex` | | Motor id | **3** | | Register | `Ofs` / `Homing_Offset` — EEPROM **addr 31**, 2 bytes | -| Offset to be written | **+1073** (wire value `1073`) | -| Where the seam ends up | raw tick 1073 — dead centre of the arc `(126, 2020)` the joint cannot reach, with 947 ticks of clearance on each side | +| Offset to be written | **+1157** (wire value `1157`) | +| Where the seam ends up | raw tick 1157 — dead centre of the arc `(207, 2107)` the joint cannot reach, with 950 ticks of clearance on each side | +| The joint's real travel | **2196 ticks**, raw `[2107, 4095] ∪ [0, 207]` — it *wraps*, which is the whole problem | You do **not** type any of those numbers. They come from `arm101/hardware/arm_spec.py` (`REZERO_ARCS`), and the offset is *derived* from the measured arc rather than typed, so it cannot drift away from it. +**Those are RAW ticks — not the ticks a servo reports.** A servo reports +`Present = (Actual − Ofs) mod 4096`, so the two frames coincide only when the +offset register holds 0, and **no servo ships that way**: the factory default is +**85**, on all six joints. To go from what you read to what the arc is talking +about: `raw = (reported + offset) mod 4096`. Getting this backwards is the bug +this procedure was rewritten to fix, on 2026-07-12 — the arc used to say +`(126, 2020)`, which were *reported* ticks measured at `Ofs = 85` and then used as +though they were raw. + Two things to have ready: - **A way to cut and restore power to the servo bus** — not just unplugging USB. @@ -44,8 +54,16 @@ uv run arm101 arm read ``` Every joint should read `ok`. Note `elbow_flex`'s `position` and `offset` -columns — the `offset` should be **0** on a servo that has never been re-zeroed. -If it is not 0, stop and read [If something looks +columns. + +On a servo that has never been re-zeroed the `offset` reads **85** — the factory +default, and you should see it on *all six* joints. That is normal and expected; +it is not something anyone did. It also means the joint's seam currently sits at +raw tick 85 — which is **inside** `elbow_flex`'s travel (the raw band `[0, 207]` +is reachable). That is issue #35, and 85 is where it actually lives. + +If the `offset` is anything else, you are not starting from a fresh servo — which +is fine, the verb reads whatever is there and converts. See [If something looks wrong](#if-something-looks-wrong). --- @@ -104,26 +122,35 @@ uv run arm101 arm rezero elbow_flex --apply At a terminal it prompts; type `yes`. What it does, in order: -1. Reads where the joint physically is, and checks that position is somewhere it - is actually *able* to be. (If not, it refuses — the table would not describe - your arm.) -2. Clears any latched overload and **disables torque**. A servo must not be - *holding* while its own frame of reference moves underneath it. -3. Opens the EEPROM lock, writes **addr 31 = 1073**, closes the lock. -4. Reads the offset back and reports it. - -You should see: +1. Reads the offset the servo is *currently* holding (85 on a fresh one) and the + position it currently reports, and converts the two into a **raw** tick: + `raw = (reported + offset) mod 4096`. +2. Checks that raw position is somewhere the joint is actually *able* to be. (If + not, it refuses — the table would not describe your arm.) +3. Asks the only question that matters: **is the seam already out of the joint's + travel?** If the offset in force already puts it inside `(207, 2107)`, the + joint is already fixed and the verb writes **nothing** — see [If something + looks wrong](#if-something-looks-wrong). +4. Otherwise: clears any latched overload and **disables torque**. A servo must + not be *holding* while its own frame of reference moves underneath it. +5. Opens the EEPROM lock, writes **addr 31 = 1157**, closes the lock. +6. Reads the offset back and reports it. + +On a factory-fresh arm (offset 85, elbow resting at raw ~126, so reporting ~41) +you should see: ```text -- offset written : +1073 (wire value 1073, EEPROM addr 31) -- offset read back : 1073 <- the write LANDED -- reported before : 126 -- reported after : 3149 (predicted 3149, delta 0) +- offset before : 85 (seam was at raw tick 85 — inside this joint's travel) +- offset written : +1157 (wire value 1157, EEPROM addr 31) +- offset read back : 1157 <- the write LANDED +- seam now at : raw tick 1157 — inside the arc the joint cannot reach +- reported before : 41 +- reported after : 3065 (predicted 3065, delta 0) ``` -The `reported after` value is the first evidence: the servo's own report jumped -from ~126 to ~3149, which is `(126 − 1073) mod 4096`. That is the modular -correction working. +The `reported after` value is the first evidence: the servo's own report jumped to +~3065, which is `(126 − 1157) mod 4096` — the raw position, corrected by the new +offset. That is the modular correction working. **If you see a WARNING here** — the position did not change, or it went negative — the offset was applied but the firmware is not using it the way we need. @@ -137,7 +164,7 @@ one that counts. Look at the arm. `elbow_flex` should be exactly where it was. Nothing commanded it to move, and nothing should have. The *reported number* -changed by 3023 ticks; the *physical joint* changed by nothing. If the joint +changed by ~3000 ticks; the *physical joint* changed by nothing. If the joint moved, stop and report it — something is commanding motion that should not be. --- @@ -151,10 +178,11 @@ power*. Then re-read: uv run arm101 arm read --json ``` -`elbow_flex`'s `offset` must **still be 1073**. +`elbow_flex`'s `offset` must **still be 1157**. -If it reverted to **0**, the write did not persist, and the EEPROM Lock dance -failed. Stop and see [If something looks wrong](#if-something-looks-wrong). +If it reverted to **85** (the factory value), the write did not persist, and the +EEPROM Lock dance failed. Stop and see [If something looks +wrong](#if-something-looks-wrong). ### Why the power-cycle is not optional @@ -176,8 +204,8 @@ This is the step the whole exercise exists for. **Do not skip it, and do not substitute step 3's read-back for it.** Reading the offset back proves it was *applied*. It does **not** prove the seam -*moved*. Only a sweep does — see [The one thing nobody -knows](#the-one-thing-nobody-knows). +*moved*. Only a sweep does — see [The thing nobody knew — and what settled +it](#the-thing-nobody-knew--and-what-settled-it). Support the elbow. Torque is about to drop and **stay** dropped. @@ -209,10 +237,12 @@ am wobbling a dead arm". | `inconclusive` | 0 | You did not move the joint through enough of its travel (or the offset was not in force). Nothing was proved either way. Just do it again, and go end to end. | | `seam-present-baseline` | 0 | The joint was not re-zeroed when you swept it. If you got this *after* step 5, the offset did not persist — go back to step 5. | -The passing report also tells you something nobody has ever known: **the real -span of `elbow_flex`'s travel.** Its far wall has never been measured, because -nothing could see across the seam. The arc table's 2202 ticks is a *lower bound*; -your sweep's `span` is the real number. Write it down. +The passing report also states **the span of `elbow_flex`'s travel**, in both +frames — the reported ticks it actually saw, and the raw ticks they convert to. +This is how the arc in `arm_spec.REZERO_ARCS` was measured in the first place (a +2196-tick sweep on 2026-07-12, the first time anything had ever seen across the +seam). If your span differs from 2196 by more than a hand's slop at the walls, the +arc is what to correct — **and it is raw ticks**, so convert before you touch it. ### If the verdict is `seam-not-evicted` @@ -238,12 +268,12 @@ uses, or unwrapping the encoder in software), not a retry. being linear.** The natural procedure would be "drive the joint to mid-travel, then centre it -there". It is exactly the procedure that must not run. `elbow_flex` currently -rests at raw tick **~126** — which is *past* its wrap. A goal of 3121 (its -mid-travel) looks like a modest move in tick-space, and is in fact a rotation -**the long way round**: from 126 down through 0, across the whole 1894-tick arc -the joint cannot reach, and into a wall. The commanded number is sane; the -physical consequence is not — and that gap *is* the bug being fixed. +there". It is exactly the procedure that must not run. `elbow_flex` rests at raw +tick **~126** — which is *past* its wrap. A goal at its mid-travel looks like a +modest move in tick-space, and is in fact a rotation **the long way round**: from +126 down through 0, across the whole 1900-tick arc the joint cannot reach, and +into a wall. The commanded number is sane; the physical consequence is not — and +that gap *is* the bug being fixed. So the verb reads where the joint physically **is**, computes the offset from the joint's known unreachable arc, and writes it. No goal position is written at any @@ -252,7 +282,7 @@ is **your hand**, in step 6 — and a human arm is the right instrument precisel because it is the one actuator in the building that does not need a linear tick axis to work. -### The one thing nobody knows +### The thing nobody knew — and what settled it One bit of firmware behaviour decides whether this whole fix works, and Feetech have never documented it: @@ -263,12 +293,39 @@ Present = raw − Ofs (signed) the seam STAYS → the fix does NOTH ``` Every source we could find — Feetech's own SDK, LeRobot, and LeRobot's shipped -SO-101 calibration procedure, which is *literally this fix* — implies the first. -None of them states it. The evidence is strong and entirely circumstantial. - -That is why step 6 exists, and why it cannot be replaced by a read-back. Your -sweep is the experiment that settles it. Full write-up: -`docs/spikes/sts3215-offset-register.md`, section 4. +SO-101 calibration procedure, which is *literally this fix* — implied the first. +None of them stated it. The evidence was strong and entirely circumstantial. + +**It is the first. Settled on this arm, 2026-07-12, by exactly the sweep in step +6.** With `Ofs = 0` it came back `monotonic: False, discontinuities: 1` — the +seam, sitting in the travel, photographed. With `Ofs = 1073` (inside the +unreachable arc) the same sweep came back `monotonic: True, discontinuities: 0` +across all 2196 ticks. The correction *is* reduced modulo 4096; the seam *does* +relocate. + +Step 6 stays anyway, and is not a formality: that is one arm and one firmware +revision, the `seam-not-evicted` verdict is what would catch a servo that behaves +differently, and a verification that cannot fail is not a verification. Full +write-up: `docs/spikes/sts3215-offset-register.md`, section 4. + +### The other thing nobody knew — the factory offset is 85, not 0 + +Everything above (and the spike) assumed a factory servo holds `Ofs = 0`. It does +not: **all six joints of this follower shipped holding 85**, and uniformly, so it +is a vendor default rather than anything anyone did. + +That mattered more than it sounds. The arc in `REZERO_ARCS` was originally +measured by reading positions off servos that were *already* correcting by −85 — +so it was in the **reported** frame, and it was then used as if it were **raw**. +The target it produced (1073) was therefore computed a whole factory-offset away +from where it was meant to be. It landed inside the true arc regardless, with +~866 ticks of margin to spare — which is the most dangerous way for a frame bug +to behave, because every read-back looks correct. A narrower arc, or a bigger +factory offset, and it would have parked the seam back inside the joint's travel +while still reporting success. + +The verb now reads whatever offset a servo holds, converts out of it, and reasons +entirely in raw ticks. ### Why `wrist_roll` is not on this list @@ -276,7 +333,7 @@ A re-zero only ever **relocates** a seam. It can never **evict** one. Eviction needs an arc the joint physically cannot reach — somewhere to put the seam where the joint will never follow it. `elbow_flex` has one: real mechanical -walls, and a 1894-tick arc between them that it cannot enter. `wrist_roll` has +walls, and a 1900-tick arc between them that it cannot enter. `wrist_roll` has none: exploration drove it right around and found **no wall anywhere** (measured free range `[21, 4073]`). Every angle is reachable, including whichever one you move the seam to. @@ -293,10 +350,12 @@ problem, and no amount of re-zeroing would have helped. | Symptom | What it means | What to do | | --- | --- | --- | -| `arm read` shows `offset` ≠ 0 before you start | The servo already holds an offset. If it is **1073**, you have already done this — skip to step 5 or 6. | If it is anything else, the verb will refuse: its reported positions are in a frame this tool did not set and cannot interpret. Restore addr 31 to 0 and start again. | -| `"reports raw encoder position N, which is INSIDE the arc"` | The joint says it is somewhere it should be physically unable to be. The arc table does not describe this arm — wrong motor, or the travel has genuinely changed. | Check motor 3 really is `elbow_flex` (`arm read`). Do **not** override it. If the travel changed, re-measure the walls and correct `REZERO_ARCS` in `arm101/hardware/arm_spec.py`; the offset is derived from that table. | +| `arm read` shows `offset` = **85** before you start | Nothing is wrong. That is the factory default, on every joint. The seam is at raw 85, inside the travel — which is the bug you are here to fix. | Carry on with step 1. | +| `--apply` says **"already re-zeroed"** and writes nothing | The offset in force already puts the seam inside `(207, 2107)`, so the seam is already out of the joint's travel — which is the entire goal. It does **not** have to equal 1157. (This arm holds **1073**, from an earlier re-zero: still evicting, still fine.) | Nothing. Skip to step 6 if you have not proved it with a sweep yet. Do not "fix" the number — rewriting a working calibration spends an EEPROM write to move a seam from one unreachable tick to another. | +| `arm read` shows some other `offset` | The verb reads whatever is there and converts (`raw = reported + offset, mod 4096`) — an unfamiliar number is not an error. | Carry on. It will either report a no-op (seam already evicted) or re-zero from that frame. | +| `"reports raw encoder position N, which is INSIDE the arc"` | The joint says it is somewhere it should be physically unable to be. The arc table does not describe this arm — wrong motor, or the travel has genuinely changed. | Check motor 3 really is `elbow_flex` (`arm read`). Do **not** override it. If the travel changed, re-measure the walls **in raw ticks** (`raw = reported + offset, mod 4096`) and correct `REZERO_ARCS` in `arm101/hardware/arm_spec.py`; the offset is derived from that table. | | `"The encoder offset did NOT take"` | The servo accepted the write and is not holding the value. | The Lock register (addr 55) is being re-closed by something else, or motor 3 is not the servo you think it is. This is PR #21's failure mode. | -| Offset reverted to 0 after the power-cycle | The write did not persist — the EEPROM Lock dance failed. | Re-run step 3. If it reverts again, that is a real bug in the Lock handling; capture it and report it. | +| Offset reverted to 85 after the power-cycle | The write did not persist — the EEPROM Lock dance failed, and the servo fell back to its factory default. | Re-run step 3. If it reverts again, that is a real bug in the Lock handling; capture it and report it. | | The arm sagged when you ran step 3 or 6 | Working as designed. Both steps de-energise the joint deliberately. | Support the arm before running them. | | The sweep says `inconclusive` | You did not move the joint far enough — over 80% of the travel is required before "no seam" means anything. | Run it again and go from one hard stop **all the way** to the other. | diff --git a/docs/spikes/sts3215-offset-register.md b/docs/spikes/sts3215-offset-register.md index 1920d21..ea4c26c 100644 --- a/docs/spikes/sts3215-offset-register.md +++ b/docs/spikes/sts3215-offset-register.md @@ -1,6 +1,23 @@ # STS3215 offset register — spike (issue #35, task t5) -**VERDICT: GO-WITH-CAVEAT.** +> **RESOLVED ON HARDWARE, 2026-07-12.** This document is kept as the record of what +> was known *before* the arm was run. Three things it left open have since been +> measured on the follower, and **two of its numbers are wrong as a result** — read +> `arm101/hardware/arm_spec.py` (`REZERO_ARCS`) and +> `docs/hardware-rezero-procedure.md` for the current facts, not §3 below. +> +> | What the spike said | What the hardware said | +> | --- | --- | +> | §2/§5: a factory servo holds `Ofs = 0` | **It holds 85.** Uniform across all six joints — a vendor default. So the positions §3 reasons from were **reported** ticks, not raw ones. | +> | §3: unreachable arc `(126, 2020)`, offset `H = 1073` | **Arc `(207, 2107)` in RAW ticks; a fresh re-zero writes 1157.** The old pair were reported-frame ticks (measured at `Ofs = 85`) used as if they were raw. 1073 landed inside the true arc anyway — by luck, with ~866 ticks of margin. | +> | §4 (the decisive caveat): is `Present` reduced mod 4096? | **Yes.** Torque-off hand sweep: at `Ofs = 0`, `monotonic: False, discontinuities: 1`; at `Ofs = 1073`, `monotonic: True, discontinuities: 0` over 2196 ticks. **The seam relocates. The fix works.** | +> | §6 unknown 5: `elbow_flex`'s far wall is unmeasured | **Measured.** Travel is 2196 ticks — raw `[2107, 4095] ∪ [0, 207]`. Not the 2202-tick lower bound §3 derives. | +> +> Still open from §6: unknowns 2 (is `Ofs` applied to `Goal_Position` too?), 3 +> (Feetech's own min/max for addr 31), 4 (does Mechanism B honour the Lock?), and 6 +> (are EEPROM writes required to be torque-off?). + +**VERDICT: GO-WITH-CAVEAT.** *(The caveat is now settled — see the banner above.)* The mechanism exists and is exactly what we hoped for: a **2-byte, EEPROM-backed, sign-magnitude offset register at address 31** (`Ofs` / `Homing_Offset`), applied by diff --git a/tests/test_arm_rezero_cli.py b/tests/test_arm_rezero_cli.py index 77e8fd2..6d11990 100644 --- a/tests/test_arm_rezero_cli.py +++ b/tests/test_arm_rezero_cli.py @@ -31,7 +31,16 @@ from arm101.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError from arm101.hardware import rezero from arm101.hardware.bus import ADDR_HOMING_OFFSET, FakeBus, OverloadError -from tests.test_rezero import ELBOW, ELBOW_MOTOR, EXPECTED_OFFSET, HandMovedBus +from tests.test_rezero import ( + ARC_HIGH, + ARC_LOW, + ELBOW, + ELBOW_MOTOR, + EXPECTED_OFFSET, + FACTORY_OFFSET, + OUR_ARM_OFFSET, + HandMovedBus, +) #: STS3215 Goal_Position — the register this verb must never, ever write. ADDR_GOAL_POSITION = 42 @@ -85,7 +94,7 @@ def _args( #: A sweep short enough to run instantly against a FakeBus (which needs no #: pacing) but long enough to walk the hand-moved shaft across the seam: #: 2.5s / 0.05s = 50 samples x 50 ticks = 2500 raw ticks, comfortably past the -#: 2202-tick travel. +#: 2196-tick travel. SWEEP_SECONDS = 2.5 SWEEP_STEP = 50 @@ -203,7 +212,7 @@ def _explode(_port): # pragma: no cover - must never be reached out = capsys.readouterr().out assert "Dry-run plan: arm rezero elbow_flex" in out - assert "addr=31, value=1073" in out # the exact wire value + assert "addr=31, value=1157" in out # the exact wire value assert "addr=55, value=0" in out and "addr=55, value=1" in out # the Lock dance assert "COMMANDS NO MOTION" in out assert "no goal position is ever written" in out @@ -220,11 +229,34 @@ def test_dry_run_json_carries_the_whole_plan(monkeypatch, capsys): assert plan["motor"] == ELBOW_MOTOR assert plan["target_offset"] == EXPECTED_OFFSET assert plan["wire_value"] == EXPECTED_OFFSET - assert plan["unreachable_arc"] == [126, 2020] + assert plan["unreachable_arc"] == [ARC_LOW, ARC_HIGH] assert plan["seam_moves_to_raw_tick"] == EXPECTED_OFFSET + assert plan["expected_travel_ticks"] == 2196 assert plan["mode"] == "write" +def test_the_dry_run_plan_SAYS_the_arc_is_raw_ticks_and_the_factory_offset_is_85( + monkeypatch, capsys +): + """The plan is where an operator meets these numbers. It must not mislead them. + + Both halves of the 2026-07-12 bug were invisible in the old plan: it printed an + arc without saying which frame it was in, and it implied a factory servo holds + 0. An operator re-measuring the arc from that plan would reproduce the bug + exactly. + """ + monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) + monkeypatch.setattr(arm_cmd, "_candidate_ports", lambda: ["/dev/ttyACM_fake"]) + + arm_cmd.cmd_arm_rezero(_args(json_mode=True)) + + plan = json.loads(capsys.readouterr().out)["plan"] + assert plan["unreachable_arc_frame"] == "raw encoder ticks (NOT the ticks a servo reports)" + assert "raw = reported + offset, mod 4096" in plan["note"] + assert f"a factory servo holds {FACTORY_OFFSET}, not 0" in plan["note"] + assert "writes NOTHING" in plan["note"] # the no-op path is announced up front + + def test_verify_dry_run_warns_that_the_arm_will_SAG(monkeypatch, capsys): """De-energising a joint that is holding a pose is a physical hazard in itself. @@ -293,13 +325,41 @@ def test_apply_writes_the_offset_and_reports_the_read_back(monkeypatch, capsys): assert payload["read_back_offset"] == EXPECTED_OFFSET assert payload["applied"] is True assert payload["plan"]["raw_position"] == 126 - assert payload["shift"]["observed_position"] == 3149 # the spike's prediction + assert payload["shift"]["observed_position"] == 3065 # (126 − 1157) mod 4096 assert payload["shift"]["as_predicted"] is True # Applied is not persistent, and applied is not evicted. Neither is claimed. assert payload["persistence_proven"] is False assert payload["seam_eviction_proven"] is False +def test_a_FACTORY_FRESH_ARM_at_offset_85_can_finally_BE_RE_ZEROED(monkeypatch, capsys): + """THE case this fix exists for: the state every SO-101 ships in. + + All six servos hold ``Ofs = 85`` out of the box. The old verb refused this + outright — *"already holds an encoder offset of 85, which is neither the + factory 0 nor this joint's computed 1073 … cannot interpret"* — so on real, + unmodified hardware ``arm rezero elbow_flex --apply`` did not work at all. It + could only ever have run on a servo somebody had already hand-zeroed. + + Now it reads 85, converts out of it, finds the shaft at raw 126, and writes. + """ + bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) + _patch_bus(monkeypatch, bus) + monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) + + arm_cmd.cmd_arm_rezero(_args(apply=True, json_mode=True)) # must NOT raise + + payload = json.loads(capsys.readouterr().out) + assert payload["plan"]["current_offset"] == FACTORY_OFFSET + assert payload["plan"]["current_seam_tick"] == 85 # inside the [0, 207] band: the bug + assert payload["plan"]["reported_position"] == 41 # 126 − 85 — a REPORTED tick + assert payload["plan"]["raw_position"] == 126 # ...and the raw one behind it + assert payload["plan"]["already_applied"] is False + assert payload["read_back_offset"] == EXPECTED_OFFSET + assert payload["applied"] is True + assert bus.offset_writes == [{"motor": ELBOW_MOTOR, "offset": EXPECTED_OFFSET}] + + def test_the_write_path_COMMANDS_NO_MOTION(monkeypatch): """The one thing a hardware run cannot forgive, pinned at the register level. @@ -355,6 +415,56 @@ def test_a_second_run_on_an_already_re_zeroed_joint_writes_nothing(monkeypatch, assert bus.register_writes == [] +def test_OUR_ARM_at_1073_is_a_clean_NO_OP_not_a_rewrite_to_1157(monkeypatch, capsys): + """The arm on the bench, and the reason "already-applied" is not "offset == target". + + Our follower holds 1073 — written by the first, frame-confused re-zero. Its + seam sits at raw 1073, strictly inside the unreachable (207, 2107), and a hand + sweep proved its travel continuous. **It is fixed.** ``arm rezero elbow_flex + --apply`` on it must therefore write NOTHING: rewriting a working calibration + to 1157 would spend an EEPROM write on a finite-write part to slide a seam + from one tick the joint can never reach to another tick the joint can never + reach. Cosmetic centring is not worth a write. + """ + bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) + _patch_bus(monkeypatch, bus) + monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) + + arm_cmd.cmd_arm_rezero(_args(apply=True, json_mode=True)) + + payload = json.loads(capsys.readouterr().out) + assert payload["written"] is False + assert payload["reason"] == "already-applied" + assert payload["plan"]["already_applied"] is True + assert payload["plan"]["current_offset"] == OUR_ARM_OFFSET + assert payload["plan"]["current_seam_tick"] == 1073 + assert payload["plan"]["target_offset"] == OUR_ARM_OFFSET # NOT 1157 + # It still TELLS you what a fresh re-zero would have written — it just doesn't. + assert payload["fresh_rezero_would_write"] == EXPECTED_OFFSET + assert payload["unreachable_arc"] == [ARC_LOW, ARC_HIGH] + + # Zero writes. Not the offset, not the lock, not torque. Nothing. + assert bus.offset_writes == [] + assert bus.register_writes == [] + assert bus.torque_writes == [] + + +def test_the_no_op_report_EXPLAINS_itself_in_text_mode(monkeypatch, capsys): + """An operator who ran --apply and saw nothing happen deserves to know why.""" + bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) + _patch_bus(monkeypatch, bus) + monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) + + arm_cmd.cmd_arm_rezero(_args(apply=True)) + + out = capsys.readouterr().out + assert "already re-zeroed" in out + assert "raw tick 1073" in out + assert f"({ARC_LOW}, {ARC_HIGH})" in out # ...and where that is + assert "Nothing written" in out + assert "1157" in out # what a fresh re-zero WOULD write, stated, not hidden + + def test_a_write_that_does_not_stick_is_an_ENV_error(monkeypatch, capsys): """The servo took the packet and is not holding the value — that is PR #21's ghost.""" @@ -397,18 +507,50 @@ def test_the_write_path_WARNS_when_the_position_does_not_move_as_predicted(monke assert "UNWRAPPED signed subtraction" in out -def test_a_servo_holding_an_unknown_offset_is_refused(monkeypatch): +def test_a_servo_holding_an_UNFAMILIAR_but_EVICTING_offset_is_a_no_op_not_a_refusal( + monkeypatch, capsys +): + """777 is nobody's computed target — and its seam is already out of the travel. + + The old verb refused any offset that was "neither the factory 0 nor this + joint's computed 1073", because it could not convert frames and would not + guess. It can convert now, so the question is answerable: raw 777 is strictly + inside (207, 2107), the joint cannot reach it, cannot cross it, and is + therefore already linear. Nothing to do, and nothing to refuse. + """ bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: 777}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) - with pytest.raises(CliError) as exc: - arm_cmd.cmd_arm_rezero(_args(apply=True)) + arm_cmd.cmd_arm_rezero(_args(apply=True, json_mode=True)) # must NOT raise - assert exc.value.code == EXIT_ENV_ERROR + payload = json.loads(capsys.readouterr().out) + assert payload["reason"] == "already-applied" + assert payload["plan"]["current_seam_tick"] == 777 assert bus.offset_writes == [] +def test_a_servo_whose_seam_is_STILL_IN_ITS_TRAVEL_is_re_zeroed_from_that_frame( + monkeypatch, capsys +): + """An offset of −1096 puts the seam at raw 3000 — in the far travel. Not evicted. + + So there IS work to do, and the verb must do it from that servo's own frame + rather than refusing because the number is unfamiliar. + """ + bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: -1096}) + _patch_bus(monkeypatch, bus) + monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) + + arm_cmd.cmd_arm_rezero(_args(apply=True, json_mode=True)) + + payload = json.loads(capsys.readouterr().out) + assert payload["plan"]["current_seam_tick"] == 3000 + assert payload["plan"]["raw_position"] == 126 # converted out of a NEGATIVE offset + assert payload["read_back_offset"] == EXPECTED_OFFSET + assert bus.offset_writes == [{"motor": ELBOW_MOTOR, "offset": EXPECTED_OFFSET}] + + def test_a_joint_reporting_an_impossible_position_is_refused(monkeypatch): bus = FakeBus(positions={ELBOW_MOTOR: 1500}) # inside its own unreachable arc _patch_bus(monkeypatch, bus) diff --git a/tests/test_bus_offset.py b/tests/test_bus_offset.py index fead351..f9777a9 100644 --- a/tests/test_bus_offset.py +++ b/tests/test_bus_offset.py @@ -54,8 +54,12 @@ ADDR_MIN_POSITION_LIMIT = 9 ADDR_MAX_POSITION_LIMIT = 11 -#: The offset issue #35 needs for elbow_flex: the midpoint of its unreachable -#: arc (126, 2020). Used throughout so the tests read as the real scenario. +#: A real, seam-evicting offset for elbow_flex — and the one our follower is +#: actually carrying. Its seam lands at raw 1073, strictly inside the measured +#: unreachable arc (207, 2107), so it does the job; a *fresh* re-zero would write +#: the arc's midpoint, 1157 (see arm_spec.REZERO_ARCS). Used throughout so the +#: codec tests read as the real scenario. What matters here is the WIRE ENCODING, +#: which is the same shape for either number. ELBOW_FLEX_OFFSET = 1073 #: ``encode_offset(-1073)`` -> ``2048 + 1073``. Spelled out because getting this @@ -658,7 +662,8 @@ def test_fakebus_present_position_shifts_by_the_offset(): ``elbow_flex`` rests at raw 126 (past its wrap). With H = 1073 the servo reports ``(126 - 1073) mod 4096 == 3149`` — and the seam it used to cross - mid-travel now sits at raw 1073, dead centre of the arc it cannot reach. + mid-travel now sits at raw 1073, strictly inside the arc it cannot reach + (``(207, 2107)``, measured 2026-07-12). Evicted, which is the whole goal. """ bus = FakeBus(positions={3: 126}) bus.open() diff --git a/tests/test_rezero.py b/tests/test_rezero.py index 85ba755..3562e9a 100644 --- a/tests/test_rezero.py +++ b/tests/test_rezero.py @@ -15,10 +15,22 @@ and a re-zero that silently did nothing. The other invariant pinned here is the one the hardware cannot forgive: this verb -**commands no motion, on any path.** ``elbow_flex`` currently rests at raw ~126, -PAST its wrap, so a linear goal would rotate it the long way round through its -whole travel and into a wall. Several tests assert the write surface directly — -no goal-position register write, ever, and torque only ever going OFF. +**commands no motion, on any path.** ``elbow_flex`` rests at raw ~126, PAST its +wrap, so a linear goal would rotate it the long way round through its whole +travel and into a wall. Several tests assert the write surface directly — no +goal-position register write, ever, and torque only ever going OFF. + +The THIRD thing pinned here, added 2026-07-12 after the arc was measured on +hardware for the first time, is the frame:: + + RAW the magnet on the shaft. The arc, the walls, the seam live here. + REPORTED what the servo says: (raw - Ofs) mod 4096. Every live read is this. + +They are equal only at ``Ofs == 0``, and **no servo ships that way** — the factory +default is 85 on all six joints. The original arc ``(126, 2020)`` was measured in +the REPORTED frame at ``Ofs = 85`` and used as if it were RAW, so the target came +out a factory-offset away from where it was meant to be. It landed inside the true +arc regardless, by luck. Tests below pin the conversion on every path. """ from __future__ import annotations @@ -41,8 +53,27 @@ ELBOW = "elbow_flex" ELBOW_MOTOR = 3 -#: The offset the spike derives (arc (126, 2020), midpoint 1073). -EXPECTED_OFFSET = 1073 +#: The MEASURED raw unreachable arc (hardware sweep, follower, 2026-07-12): the +#: travel is raw [2107, 4095] ∪ [0, 207], so (207, 2107) is what it cannot reach. +ARC_LOW = 207 +ARC_HIGH = 2107 + +#: What a FRESH re-zero writes: the arc's midpoint, (207 + 2107) // 2. +EXPECTED_OFFSET = 1157 + +#: What a factory-fresh STS3215 actually holds. **Not 0.** Measured uniform across +#: all six joints of the follower — this is the state of every un-touched SO-101, +#: and the state the verb used to REFUSE outright. +FACTORY_OFFSET = 85 + +#: What OUR follower holds right now, written by the first (frame-confused) +#: re-zero. Its seam sits at raw 1073, which is strictly inside (207, 2107) — so +#: the seam IS evicted and the arm IS fixed, even though 1073 != 1157. Re-zeroing +#: it again must be a NO-OP. +OUR_ARM_OFFSET = 1073 + +#: ``elbow_flex``'s measured travel: 4096 − (2107 − 207). +EXPECTED_TRAVEL = 2196 #: STS3215 Goal_Position. Must NEVER appear in this verb's write surface. ADDR_GOAL_POSITION = 42 @@ -70,18 +101,18 @@ class HandMovedBus(FakeBus): Parameters ---------- start_raw: - Raw tick the shaft begins at. Defaults to ``elbow_flex``'s hard wall - (2020), the end a human is told to start from. + RAW tick the shaft begins at. Defaults to ``elbow_flex``'s measured near + wall (raw 2107), the end a human is told to start from. ticks_per_read: How far the hand advances the joint between two polls. Positive winds - toward the seam (2020 -> 4095 -> 0 -> 126), which is the direction that - crosses it. + toward the raw seam (2107 -> 4095 -> 0 -> 207), which is the direction + that crosses it. """ def __init__( self, *args, - start_raw: int = 2020, + start_raw: int = ARC_HIGH, ticks_per_read: int = 25, motor: int = ELBOW_MOTOR, **kwargs, @@ -111,21 +142,40 @@ def _rezeroed_bus(**kwargs) -> HandMovedBus: # --------------------------------------------------------------------------- -def test_elbow_flex_offset_matches_the_spike_arithmetic(): - """The offset is 1073 — the midpoint of the measured unreachable arc (126, 2020).""" - assert arm_spec.rezero_offset(ELBOW) == EXPECTED_OFFSET +def test_the_arc_is_the_one_MEASURED_on_hardware_in_RAW_ticks(): + """The bug, pinned. The arc is (207, 2107) — raw ticks, from a real sweep. + + The table shipped with ``(126, 2020)``, which were REPORTED ticks read off a + servo already holding the factory offset of 85, used as if they were raw. The + 2026-07-12 hand sweep (torque off, seam already evicted, so the encoder could + finally be walked across its own wrap) measured the travel as reported + 1034..3230 at ``Ofs = 1073`` — which is raw [2107, 4095] ∪ [0, 207], and so + the arc it cannot reach is (207, 2107). + """ + arc = arm_spec.rezero_arc(ELBOW) + assert (arc.low, arc.high) == (ARC_LOW, ARC_HIGH) + # And the raw travel really is what the corrected sweep saw, converted back. + assert rezero.raw_from_reported(1034, OUR_ARM_OFFSET) == ARC_HIGH # near wall + assert rezero.raw_from_reported(3230, OUR_ARM_OFFSET) == ARC_LOW # far wall, at last + + +def test_elbow_flex_offset_is_the_midpoint_of_the_measured_arc(): + """A fresh re-zero writes 1157 — the midpoint of (207, 2107).""" + assert arm_spec.rezero_offset(ELBOW) == EXPECTED_OFFSET == (ARC_LOW + ARC_HIGH) // 2 def test_offset_is_derived_from_the_arc_not_typed(): """Correct the arc and the offset follows — the two cannot drift apart. - The far wall has never been measured (nothing could see across the seam), so - the arc WILL be corrected once ``--verify`` measures it. If the offset were a - typed constant, that correction would silently leave it stale. + Not hypothetical: this is exactly what happened on 2026-07-12. The arc was + re-measured in the right frame, one tuple changed, and the target moved from + 1073 to 1157 without a line of ``rezero_offset`` being touched. Had the offset + been a typed constant, the correction would have silently left it stale — and + a stale target is a seam written into a joint's live travel. """ arc = arm_spec.rezero_arc(ELBOW) assert arc is not None - assert arm_spec.rezero_offset(ELBOW) == arc.midpoint + assert arm_spec.rezero_offset(ELBOW) == arc.midpoint == arc.offset def test_the_seam_lands_strictly_inside_the_unreachable_arc(): @@ -133,33 +183,102 @@ def test_the_seam_lands_strictly_inside_the_unreachable_arc(): arc = arm_spec.rezero_arc(ELBOW) offset = arm_spec.rezero_offset(ELBOW) assert arc.contains(offset) + assert arc.evicts(offset) # ...with real clearance on both sides, not by one tick. - assert offset - arc.low == 947 - assert arc.high - offset == 947 + assert offset - arc.low == 950 + assert arc.high - offset == 950 def test_offset_fits_the_registers_sign_magnitude_range(): - """±2047 is the register's whole world; 1073 sits comfortably inside it.""" + """±2047 is the register's whole world; 1157 sits comfortably inside it.""" assert abs(arm_spec.rezero_offset(ELBOW)) <= arm_spec.MAX_ENCODER_OFFSET def test_arc_arithmetic_reconstructs_the_measured_travel(): - """Travel = 4096 − arc width = 2202 ticks, exactly as the spike derives it.""" + """Travel = 4096 − arc width = 2196 ticks — the number the sweep actually saw.""" arc = arm_spec.rezero_arc(ELBOW) - assert arc.width == 1894 - assert arc.travel_ticks == 2202 + assert arc.width == 1900 + assert arc.travel_ticks == EXPECTED_TRAVEL def test_arc_endpoints_are_reachable_and_the_interior_is_not(): """``contains`` is the OPEN interval — the endpoints are the joint's own walls.""" arc = arm_spec.rezero_arc(ELBOW) - assert not arc.contains(126) # its rest position, past the wrap - assert not arc.contains(2020) # its measured hard wall - assert arc.contains(127) - assert arc.contains(2019) + assert not arc.contains(ARC_LOW) # the far wall, measured 2026-07-12 + assert not arc.contains(ARC_HIGH) # the near wall + assert arc.contains(ARC_LOW + 1) + assert arc.contains(ARC_HIGH - 1) + assert not arc.contains(126) # its rest position: raw, past the wrap, reachable assert not arc.contains(3000) # in the travel, on the far side of the seam +# --- the frame: RAW ticks vs REPORTED ticks -------------------------------- + + +def test_the_factory_offset_is_85_and_it_is_NOT_zero(): + """The measurement that broke the old reasoning open. + + Every source (and the spike) assumed a factory servo holds 0. All six joints + of the follower held **85**, straight out of the box — uniform, so a vendor + default rather than a per-servo calibration. It means a "factory" servo's + reported ticks were never raw ticks, and the arc measured on one was never a + raw arc. + """ + assert arm_spec.FACTORY_ENCODER_OFFSET == FACTORY_OFFSET != 0 + + +def test_a_FACTORY_servos_seam_sits_INSIDE_elbow_flexs_travel(): + """Which is to say: the factory default IS issue #35, and 85 is where it lives. + + ``elbow_flex``'s travel includes the raw band [0, 207]. A factory servo puts + its seam at raw 85 — right in it. Nothing is evicted; the joint wraps + mid-travel; that is the bug. + """ + arc = arm_spec.rezero_arc(ELBOW) + assert arm_spec.seam_tick(FACTORY_OFFSET) == 85 + assert not arc.contains(85) + assert not arc.evicts(FACTORY_OFFSET) + + +def test_seam_tick_reduces_a_SIGNED_offset_modulo_4096(): + """The register is signed; the encoder is a circle. −1096 means raw 3000. + + Comparing the signed number straight against a raw arc would place the seam a + whole turn from where it physically is. + """ + assert arm_spec.seam_tick(0) == 0 + assert arm_spec.seam_tick(FACTORY_OFFSET) == 85 + assert arm_spec.seam_tick(EXPECTED_OFFSET) == EXPECTED_OFFSET + assert arm_spec.seam_tick(-1096) == 3000 + assert arm_spec.seam_tick(-1) == 4095 + + +def test_seam_tick_and_offset_for_seam_at_are_inverses(): + """The round-trip the whole re-zero rests on, pinned across the register's range.""" + for tick in (0, 1, 85, 207, 1073, 1157, 2047, 2049, 3000, 4095): + assert arm_spec.seam_tick(arm_spec._offset_for_seam_at(tick)) == tick + + +def test_evicts_is_about_WHERE_THE_SEAM_IS_not_which_number_the_register_holds(): + """The second half of the fix, and the one that saves our arm an EEPROM write. + + ~1899 different offsets evict ``elbow_flex``'s seam. The midpoint is merely + the roomiest. An arm holding ANY of them is fixed — and ours holds 1073. + """ + arc = arm_spec.rezero_arc(ELBOW) + + assert arc.evicts(OUR_ARM_OFFSET) # our follower: 1073, from the first re-zero + assert arc.evicts(EXPECTED_OFFSET) # the canonical midpoint: 1157 + assert arc.evicts(ARC_LOW + 1) # one tick inside: ugly, but evicted + assert arc.evicts(ARC_HIGH - 1) + + assert not arc.evicts(0) # seam at raw 0 — in the [0, 207] band + assert not arc.evicts(FACTORY_OFFSET) # seam at raw 85 — likewise + assert not arc.evicts(ARC_LOW) # the wall itself is REACHABLE + assert not arc.evicts(ARC_HIGH) + assert not arc.evicts(-1096) # seam at raw 3000 — deep in the far travel + + @pytest.mark.parametrize("joint", ["shoulder_pan", "shoulder_lift", "wrist_flex", "gripper"]) def test_non_wrapping_joints_are_refused_as_UNNECESSARY(joint): """Four joints do not wrap at all: there is no seam to evict, and they say so.""" @@ -236,7 +355,7 @@ def test_a_high_arc_uses_the_NEGATIVE_congruent_offset(): def test_malformed_arcs_are_rejected_by_the_dataclass(): - for low, high in ((2020, 126), (100, 100), (-1, 500), (500, 4096)): + for low, high in ((2107, 207), (100, 100), (-1, 500), (500, 4096)): with pytest.raises(ValueError, match="Invalid unreachable arc"): arm_spec.UnreachableArc(low=low, high=high) @@ -264,7 +383,7 @@ def test_arm_spec_encoder_constants_agree_with_the_bus(): def test_require_rezeroable_returns_the_offset_and_the_arc(): offset, arc = rezero.require_rezeroable(ELBOW) assert offset == EXPECTED_OFFSET - assert (arc.low, arc.high) == (126, 2020) + assert (arc.low, arc.high) == (ARC_LOW, ARC_HIGH) def test_require_rezeroable_refuses_wrist_roll_with_the_full_reason(): @@ -289,15 +408,16 @@ def test_require_rezeroable_rejects_an_unknown_joint_as_a_user_error(): def test_plan_reads_the_live_state_and_writes_nothing(): - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: 126}) # raw 126; a servo somebody zeroed bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) assert plan.current_offset == 0 + assert plan.current_seam_tick == 0 # seam at raw 0 — inside the travel assert plan.target_offset == EXPECTED_OFFSET assert plan.reported_position == 126 - assert plan.raw_position == 126 # factory offset: reported IS raw, no assumption + assert plan.raw_position == 126 # at offset 0, and ONLY at 0, reported IS raw assert plan.already_applied is False # Planning is a read. Not one register was touched. assert bus.register_writes == [] @@ -305,39 +425,99 @@ def test_plan_reads_the_live_state_and_writes_nothing(): assert bus.torque_writes == [] -def test_plan_predicts_the_position_the_spike_predicts(): - """From rest at raw 126, an offset of 1073 must make the servo report 3149. +def test_a_FACTORY_FRESH_servo_at_offset_85_IS_PLANNED_not_refused(): + """THE case this fix exists for — and the one the old guard blocked outright. + + Every un-touched SO-101 holds ``Ofs = 85`` on every joint. The old + ``plan_rezero`` refused anything that was "neither the factory 0 nor this + joint's computed 1073", so on real, factory hardware the verb did not work + **at all**. It could only ever have run on a servo somebody had already + hand-zeroed. - Spike §3 step 7: reported = (126 − 1073) mod 4096 = 3149. If this number is - wrong, everything downstream of it is decoration. + Now it reads the offset, converts out of it, and plans. The shaft is at raw + 126, so a servo holding 85 reports 41 — and the plan must find raw 126 behind + that, not 41. + """ + bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) + bus.open() + + plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + + assert plan.current_offset == FACTORY_OFFSET + assert plan.current_seam_tick == 85 # the factory seam, INSIDE the [0, 207] band + assert plan.reported_position == 41 # 126 − 85: a REPORTED tick + assert plan.raw_position == 126 # ...and the raw tick behind it + assert plan.already_applied is False # 85 does not evict: there is work to do + assert plan.target_offset == EXPECTED_OFFSET + assert bus.register_writes == [] + + +def test_plan_predicts_what_the_servo_will_report_after_the_write(): + """From rest at raw 126, an offset of 1157 must make the servo report 3065. + + ``(126 − 1157) mod 4096 = 3065``. If this number is wrong, everything + downstream of it — the shift probe, the operator's sanity check — is + decoration. """ bus = FakeBus(positions={ELBOW_MOTOR: 126}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) - assert plan.predicted_position == 3149 + assert plan.predicted_position == 3065 @pytest.mark.parametrize( ("raw", "predicted"), - [(2020, 947), (4060, 2987), (4095, 3022), (0, 3023), (126, 3149)], + [(2107, 950), (4000, 2843), (4095, 2938), (0, 2939), (126, 3065), (207, 3146)], ) def test_the_whole_travel_becomes_one_contiguous_increasing_interval(raw, predicted): - """Spike §3's table, row by row: 947 -> 2987 -> 3022 -> 3023 -> 3149. + """The deliverable of issue #35, row by row: 950 -> 2938 -> 2939 -> 3146. - Strictly increasing, no discontinuity — the reachable set collapses to the - single interval [947, 3149], which a (min, max) pair can honestly describe. - That is the entire deliverable of issue #35. + Walk the raw travel (2107 -> 4095 -> |raw seam| -> 0 -> 207) and the reported + values come out strictly increasing with no discontinuity: the reachable set + collapses to the single interval [950, 3146], which a ``(min, max)`` pair can + honestly describe. Note the crossing at raw 4095 -> 0: reported 2938 -> 2939, + one tick, no seam. That is the entire point. """ bus = FakeBus(positions={ELBOW_MOTOR: raw}) bus.open() assert rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW).predicted_position == predicted -def test_plan_is_idempotent_on_an_already_re_zeroed_joint(): +def test_OUR_ARM_holding_1073_is_a_NO_OP_not_a_rewrite(): + """The arm on the bench. It holds 1073 and it is DONE — do not touch it. + + 1073 came out of the first, frame-confused re-zero. It is not the midpoint + (that is 1157) and it does not have to be: its seam sits at raw 1073, strictly + inside the unreachable ``(207, 2107)``, and a torque-off hand sweep proved the + travel continuous across all 2196 ticks. The seam is out of the joint's + travel. That IS the goal, and it is met. + + A verb that insisted on its own midpoint would burn an EEPROM write on a + finite-write part to slide a seam from one unreachable tick to another + unreachable tick, and the joint could not tell the difference. + """ + bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) + bus.open() + + plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + + assert plan.already_applied is True # <- the whole test + assert plan.current_offset == OUR_ARM_OFFSET + assert plan.current_seam_tick == 1073 + assert plan.target_offset == OUR_ARM_OFFSET # NOT 1157: nothing is being written + assert plan.reported_position == 3149 # (126 − 1073) mod 4096 + assert plan.raw_position == 126 # ...and we can still find the shaft + assert plan.predicted_position == plan.reported_position # nothing will change + assert bus.register_writes == [] + assert bus.offset_writes == [] + + +def test_plan_is_idempotent_on_a_joint_holding_the_canonical_midpoint_too(): """The procedure sends the operator away to power-cycle and come back. - A second run against an already-re-zeroed joint is therefore the EXPECTED - path, not a mistake, and it must be recognised rather than re-written. + A second run against an already-re-zeroed joint is the EXPECTED path, not a + mistake, and it must be recognised rather than re-written — whether the offset + in force is the midpoint or any other evicting one. """ bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: EXPECTED_OFFSET}) bus.open() @@ -346,26 +526,69 @@ def test_plan_is_idempotent_on_an_already_re_zeroed_joint(): assert plan.already_applied is True assert plan.current_offset == EXPECTED_OFFSET - assert plan.reported_position == 3149 # the servo is already reporting corrected + assert plan.reported_position == 3065 # the servo is already reporting corrected assert plan.raw_position == 126 # ...and we can still find the shaft assert bus.register_writes == [] -def test_plan_refuses_a_servo_holding_an_UNRECOGNISED_offset(): - """We cannot honestly convert its reports to raw ticks, so we do not pretend to. +def test_ANY_offset_that_already_evicts_the_seam_is_a_no_op(): + """500 is nobody's computed target — and its seam is already out of the travel. - Writing a new offset on top of an unknown one would bury the problem in - EEPROM instead of surfacing it. + The old guard refused this outright ("neither the factory 0 nor this joint's + computed 1073"). But raw 500 is strictly inside (207, 2107): the joint cannot + reach it, cannot cross it, and is therefore already linear. There is nothing + to do, and "I don't recognise this number" was never a reason to say otherwise. """ bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: 500}) bus.open() - with pytest.raises(CliError) as exc: - rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + + assert plan.already_applied is True + assert plan.current_seam_tick == 500 + assert bus.register_writes == [] - assert exc.value.code == EXIT_ENV_ERROR - assert "already holds an encoder offset of 500" in exc.value.message - assert bus.register_writes == [] # refused BEFORE touching anything + +def test_an_offset_whose_seam_is_IN_the_travel_is_re_zeroed_FROM_ITS_OWN_FRAME(): + """A servo holding −1096 has its seam at raw 3000 — deep in the far travel. + + Not evicted, so there IS work to do. And its reports are shifted by −1096, so + the plan must convert out of that frame (not the factory's, not the target's) + to find the shaft. Exercises the negative-offset path, which is where a + two's-complement or a sign-blind modulo would come apart. + """ + bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: -1096}) + bus.open() + + plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + + assert plan.current_seam_tick == 3000 # −1096 mod 4096 — a tick, on the circle + assert plan.already_applied is False + assert plan.reported_position == 1222 # (126 + 1096) mod 4096 + assert plan.raw_position == 126 # ...converted back out of ITS frame + assert plan.target_offset == EXPECTED_OFFSET + + +def test_the_raw_conversion_WRAPS_modulo_4096(): + """``reported + offset`` genuinely runs past 4096, and folds. It must. + + A joint reporting 4000 on a servo holding +200 is physically at raw 104 — + there is no tick 4200. Without the modulo the plan would place the shaft + outside the encoder entirely and then compare that nonsense against the arc. + """ + assert rezero.raw_from_reported(4000, 200) == 104 + assert rezero.raw_from_reported(4095, 1) == 0 + assert rezero.raw_from_reported(0, -1) == 4095 # and it folds the other way too + + bus = FakeBus(positions={ELBOW_MOTOR: 104}, offsets={ELBOW_MOTOR: 200}) + bus.open() + + plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + + assert plan.reported_position == 4000 # (104 − 200) mod 4096 — near the top + assert plan.raw_position == 104 # ...and back down, over the wrap + assert plan.already_applied is False # seam at raw 200 is in the [0, 207] band + assert plan.target_offset == EXPECTED_OFFSET def test_plan_refuses_a_joint_that_reports_a_position_it_cannot_physically_hold(): @@ -375,8 +598,13 @@ def test_plan_refuses_a_joint_that_reports_a_position_it_cannot_physically_hold( Either way the offset about to be written comes from a table that does not describe the hardware — and writing it would put the seam somewhere the joint CAN go, making issue #35 worse, persistently, in EEPROM. + + This is the guard that survives, and it is the one that would have caught the + frame bug had the numbers been less lucky: it is the only check that compares + a live reading against the arc, so it is the only one that can notice they are + in different frames. """ - bus = FakeBus(positions={ELBOW_MOTOR: 1500}) # dead centre of (126, 2020) + bus = FakeBus(positions={ELBOW_MOTOR: 1500}) # dead centre of (207, 2107) bus.open() with pytest.raises(CliError) as exc: @@ -385,13 +613,35 @@ def test_plan_refuses_a_joint_that_reports_a_position_it_cannot_physically_hold( assert exc.value.code == EXIT_ENV_ERROR assert "INSIDE the arc" in exc.value.message assert "Refusing to write" in exc.value.message + assert "raw = reported + offset" in exc.value.remediation # it names the frame assert bus.register_writes == [] -def test_raw_from_reported_is_the_identity_at_the_factory_offset(): - """The case the whole procedure is designed around carries NO assumption.""" - assert rezero.raw_from_reported(126, 0) == 126 - assert rezero.raw_from_reported(3149, EXPECTED_OFFSET) == 126 # and it inverts +def test_the_impossible_position_guard_judges_the_RAW_tick_not_the_reported_one(): + """The same guard, in a frame. Reported 1415 at Ofs=85 is raw 1500 — impossible. + + A servo whose shaft is genuinely in the unreachable arc must be caught + whatever offset it happens to be holding, because the arc is a fact about the + shaft. Judging the reported tick instead would let a servo 85 ticks deep into + an arc it cannot reach look perfectly healthy. + """ + bus = FakeBus(positions={ELBOW_MOTOR: 1500}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) + bus.open() + + with pytest.raises(CliError) as exc: + rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + + assert exc.value.code == EXIT_ENV_ERROR + assert "raw encoder position 1500" in exc.value.message + assert "it reports 1415 while holding an offset of 85" in exc.value.message + + +def test_raw_from_reported_inverts_the_servos_own_correction(): + """``Actual = (Present + Ofs) mod 4096``, on every path — not just exotic ones.""" + assert rezero.raw_from_reported(126, 0) == 126 # identity ONLY at offset 0 + assert rezero.raw_from_reported(41, FACTORY_OFFSET) == 126 # the factory frame + assert rezero.raw_from_reported(3065, EXPECTED_OFFSET) == 126 # the target frame + assert rezero.raw_from_reported(3149, OUR_ARM_OFFSET) == 126 # our arm's frame # --------------------------------------------------------------------------- @@ -527,14 +777,36 @@ def test_shift_matches_the_prediction_when_the_offset_wraps(): shift = rezero.describe_shift(plan, bus.read_position(ELBOW_MOTOR)) - assert shift["observed_position"] == 3149 + assert shift["observed_position"] == 3065 assert shift["as_predicted"] is True assert shift["in_range"] is True assert shift["unchanged"] is False +def test_shift_matches_the_prediction_from_the_FACTORY_frame_too(): + """The probe has to survive the frame conversion, or it warns on every real arm. + + A factory servo at ``Ofs = 85`` reports 41 from rest. After the write it must + report ``(126 − 1157) mod 4096 = 3065`` — the same place, because the SHAFT did + not move; only the frame did. A probe that predicted from the reported 41 + instead of the raw 126 would be off by exactly the factory offset and would cry + wolf on every single fresh arm. + """ + bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) + bus.open() + plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + rezero.apply_rezero(bus, ELBOW_MOTOR, plan.target_offset) + + shift = rezero.describe_shift(plan, bus.read_position(ELBOW_MOTOR)) + + assert shift["predicted_position"] == 3065 + assert shift["observed_position"] == 3065 + assert shift["as_predicted"] is True + assert shift["unchanged"] is False + + def test_shift_catches_the_signed_reading_IMMEDIATELY(): - """Under ``offset_wraps=False`` the servo reports −947 from rest — impossible. + """Under ``offset_wraps=False`` the servo reports −1031 from rest — impossible. A position register cannot hold a negative number, so this alone already proves the corrected position is an unwrapped signed subtraction and the @@ -548,7 +820,7 @@ def test_shift_catches_the_signed_reading_IMMEDIATELY(): shift = rezero.describe_shift(plan, bus.read_position(ELBOW_MOTOR)) - assert shift["observed_position"] == 126 - EXPECTED_OFFSET == -947 + assert shift["observed_position"] == 126 - EXPECTED_OFFSET == -1031 assert shift["in_range"] is False assert shift["as_predicted"] is False @@ -564,14 +836,13 @@ def _analyse(positions, offset_in_force=EXPECTED_OFFSET): joint=ELBOW, motor=ELBOW_MOTOR, offset_in_force=offset_in_force, - expected_offset=EXPECTED_OFFSET, - expected_travel=2202, + arc=arm_spec.rezero_arc(ELBOW), ) def test_a_clean_full_sweep_is_a_PASS(): - """947 -> 3149, monotonic, no jump: the seam is gone. Issue #35 is fixed.""" - report = _analyse(list(range(947, 3150, 25))) + """950 -> 3146, monotonic, no jump: the seam is gone. Issue #35 is fixed.""" + report = _analyse(list(range(950, 3147, 25))) assert report.continuous is True assert report.conclusive is True @@ -580,7 +851,28 @@ def test_a_clean_full_sweep_is_a_PASS(): assert report.verdict == rezero.VERDICT_SEAM_EVICTED assert report.failed is False assert report.largest_jump == 25 - assert (report.minimum, report.maximum) == (947, 3147) + assert (report.minimum, report.maximum) == (950, 3125) + assert report.expected_travel == EXPECTED_TRAVEL # derived from the arc, not passed + + +def test_a_sweep_of_OUR_ARM_at_1073_is_a_PASS_not_an_INCONCLUSIVE(): + """The report must not deny the measurement in front of it. + + Our follower holds 1073, which is not the arc's midpoint (1157) and does not + need to be: its seam sits at raw 1073, strictly inside (207, 2107). Judging + ``rezeroed`` by ``offset == expected_offset`` would call this arm un-re-zeroed + and downgrade the very sweep that PROVED the fix works to "inconclusive" — + on the grounds that a register held the wrong integer. + """ + report = _analyse(list(range(1034, 3231, 25)), offset_in_force=OUR_ARM_OFFSET) + + assert report.seam_tick == 1073 + assert report.rezeroed is True # <- the whole test + assert report.continuous is True + assert report.conclusive is True + assert report.verdict == rezero.VERDICT_SEAM_EVICTED + # It still reports the canonical target, it just does not JUDGE by it. + assert report.expected_offset == EXPECTED_OFFSET def test_a_4095_to_0_wrap_under_a_written_offset_is_the_STOP_condition(): @@ -607,16 +899,17 @@ def test_the_MASKED_signed_jump_is_caught_too_and_it_is_why_the_threshold_is_not """The subtlest way the caveat can bite, and the one a lazy threshold misses. If the firmware does a plain signed subtraction AND ``read_position``'s - ``& 0x0FFF`` folds the negative result back into range, the discontinuity at - the seam is only ~1949 ticks — comfortably UNDER the tempting 2048 threshold. - A 2048 threshold would call this a pass and tell the operator the fix works. + ``& 0x0FFF`` folds the negative result back into range, the report falls from + ``4095 − H`` to ``H`` at the seam: a jump of ``4095 − 2H``, only **1781 ticks** + at ``H = 1157`` — comfortably UNDER the tempting 2048 threshold. A 2048 + threshold would call this a pass and tell the operator the fix works. """ - jump = 3022 - 1073 - assert jump == 1949 < 2048 # the trap, stated - report = _analyse([2900, 2960, 3022, 1073, 1140, 1200]) + jump = (4095 - EXPECTED_OFFSET) - EXPECTED_OFFSET + assert jump == 1781 < 2048 # the trap, stated + report = _analyse([2900, 2930, 2938, 1157, 1200, 1250]) - assert report.largest_jump == 1949 - assert rezero.DISCONTINUITY_TICKS <= 1949 + assert report.largest_jump == 1781 + assert rezero.DISCONTINUITY_TICKS <= 1781 assert report.continuous is False assert report.failed is True @@ -628,9 +921,9 @@ def test_an_impossible_negative_reading_fails_INDEPENDENTLY_of_the_jump(): the unwrapped-signed firmware alone, and neither is asked to carry it by itself. """ - report = _analyse([-947, -900, -850, -800]) + report = _analyse([-1031, -900, -850, -800]) - assert report.out_of_range == (-947, -900, -850, -800) + assert report.out_of_range == (-1031, -900, -850, -800) assert report.continuous is False # despite every delta being a tame 47 ticks assert report.largest_jump < rezero.DISCONTINUITY_TICKS assert report.failed is True @@ -652,7 +945,7 @@ def test_an_unre_zeroed_discontinuous_sweep_is_a_BASELINE_not_a_failure(): def test_a_short_clean_sweep_is_INCONCLUSIVE_never_a_pass(): """The most dangerous outcome available, and the one it must never claim. - A sweep that moved the joint 200 of its 2202 ticks and saw no seam has proved + A sweep that moved the joint 190 of its 2196 ticks and saw no seam has proved NOTHING — of course it saw no seam; it never went near where the seam would be. Reporting that as a pass would close the open question with a lie. """ @@ -668,15 +961,23 @@ def test_a_short_clean_sweep_is_INCONCLUSIVE_never_a_pass(): assert "one hard stop ALL THE WAY to the other" in report.describe() -def test_a_clean_sweep_of_a_joint_that_was_never_re_zeroed_is_INCONCLUSIVE(): - """No offset was in force, so nothing about the offset was tested.""" - report = _analyse(list(range(947, 3150, 25)), offset_in_force=0) +def test_a_clean_sweep_of_a_joint_whose_seam_is_STILL_IN_ITS_TRAVEL_is_INCONCLUSIVE(): + """The seam was never evicted, so nothing about the eviction was tested. + Note the judgement is on WHERE THE SEAM IS, not on which number the register + held: a factory servo at ``Ofs = 85`` has its seam at raw 85, inside the + joint's travel, and is exactly as un-re-zeroed as one at 0. + """ + report = _analyse(list(range(950, 3147, 25)), offset_in_force=FACTORY_OFFSET) + + assert report.seam_tick == 85 # inside the reachable [0, 207] band + assert report.rezeroed is False assert report.continuous is True assert report.conclusive is True # it covered the travel... - assert report.seam_evicted is False # ...but there was no re-zero to prove + assert report.seam_evicted is False # ...but there was no eviction to prove assert report.verdict == rezero.VERDICT_INCONCLUSIVE assert "was NOT re-zeroed" in report.describe() + assert "seam at raw tick 85" in report.describe() def test_a_DISCONTINUOUS_sweep_is_conclusive_however_short_it_was(): @@ -723,18 +1024,23 @@ def test_a_single_sample_cannot_be_judged_and_says_so(): assert "too few to judge" in exc.value.message -def test_the_report_measures_the_far_wall_for_the_first_time(): - """Free, and genuinely new: nothing could see across the seam before. +def test_a_passing_report_states_the_travel_it_measured_IN_BOTH_FRAMES(): + """This is the measurement that corrects the arc — so it must be usable as one. - The arc table was built on a LOWER BOUND for travel (2202 ticks) because - ``arm explore`` could not drive past the wrap. A passing sweep measures the - real number, and says so — which is what lets the arc, and the offset derived - from it, be corrected. + A sweep runs in the REPORTED frame (that is all a servo can speak), and the arc + is RAW. A report that handed back only the reported endpoints would be handing + the next person exactly the numbers that caused this bug: ticks in one frame, + destined for a table in the other. So it converts, and it says which is which. """ - report = _analyse(list(range(947, 3300, 25))) # a longer travel than we assumed + report = _analyse(list(range(950, 3147, 25))) text = report.describe() - assert "Far wall measured for the first time" in text + + assert "Travel measured" in text assert f"{report.span} ticks" in text + assert "RAW" in text + # 950 and 3125 reported at Ofs=1157 are raw 2107 and 186 — over the wrap. + assert "RAW 2107 .. 186" in text + assert "mod 4096" in text # and it tells you how to convert def test_report_json_payload_is_complete_and_serialisable(): @@ -748,6 +1054,10 @@ def test_report_json_payload_is_complete_and_serialisable(): assert payload["failed"] is True assert payload["discontinuities"] == [{"index": 1, "before": 4090, "after": 5}] assert payload["discontinuity_threshold"] == rezero.DISCONTINUITY_TICKS + # The frame is in the payload, not just the prose. + assert payload["seam_tick"] == EXPECTED_OFFSET + assert payload["unreachable_arc"] == [ARC_LOW, ARC_HIGH] + assert payload["expected_travel"] == EXPECTED_TRAVEL # --------------------------------------------------------------------------- @@ -771,7 +1081,7 @@ def test_sweep_PROVES_the_seam_moved_when_the_offset_wraps(): assert report.seam_evicted is True assert report.verdict == rezero.VERDICT_SEAM_EVICTED assert report.largest_jump == 25 # the hand's own step, and nothing bigger - assert report.minimum == 947 # the wall, in the corrected frame + assert report.minimum == 950 # raw 2107, the near wall, in the corrected frame assert max(report.samples) >= 3100 # ...all the way past rest @@ -845,6 +1155,29 @@ def test_sweep_reports_the_offset_ACTUALLY_in_force_not_the_one_we_hoped_for(): assert report.rezeroed is False +def test_sweep_of_OUR_ARM_at_1073_PROVES_the_seam_moved(): + """End to end, on the offset the real follower is actually carrying. + + 1073 is not the midpoint and never will be. Its seam is at raw 1073, inside + (207, 2107), so the joint cannot cross it — and the sweep says so, in full, as + ``seam-evicted``. This is the run that was done on hardware on 2026-07-12 + (``monotonic: True, discontinuities: 0`` over 2196 ticks); if the code cannot + return a PASS for it, the code is disagreeing with the arm. + """ + bus = HandMovedBus(offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) + bus.open() + + report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=90) + + assert report.offset_in_force == OUR_ARM_OFFSET + assert report.seam_tick == 1073 + assert report.rezeroed is True + assert report.continuous is True + assert report.seam_evicted is True + assert report.verdict == rezero.VERDICT_SEAM_EVICTED + assert report.minimum == 1034 # raw 2107 − 1073 — the reported near wall, as measured + + def test_sweep_invokes_the_on_sample_hook_for_every_poll(): """The operator needs to SEE the position moving, or they cannot tell they are driving the joint from merely holding a dead arm.""" From 505f31e7b84095e18bef814e41d95ff92d4355c7 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sun, 12 Jul 2026 15:44:49 +0300 Subject: [PATCH 3/7] =?UTF-8?q?wip:=20arc=20with=20margin=20(318,=202007)?= =?UTF-8?q?=20=E2=80=94=20tests=20still=20hardcode=20the=20old=20numbers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eidetic/memory/arm101-cli__public.jsonl | 2 + arm101/hardware/arm_spec.py | 68 ++++++++++++-- docs/hardware-rezero-run-2026-07-12.md | 113 +++++++++++++++++++++++ 3 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 docs/hardware-rezero-run-2026-07-12.md diff --git a/.eidetic/memory/arm101-cli__public.jsonl b/.eidetic/memory/arm101-cli__public.jsonl index 308594f..a2a696d 100644 --- a/.eidetic/memory/arm101-cli__public.jsonl +++ b/.eidetic/memory/arm101-cli__public.jsonl @@ -13,3 +13,5 @@ {"id": "usb-yank-cannot-be-fixed-in-software", "hash": "a6744430cda0c6d8dc7d991854d125b9f3636a6779ef710a3d85c93af009da05", "content": "HARD LIMIT (2026-07-12, found while drafting t4 hardware acceptance for #33): A PHYSICAL USB YANK CANNOT BE RECOVERED IN SOFTWARE, and I wrongly wrote it into the spec as an achievable acceptance criterion (honesty conditions h1/h25) before catching it. The SO-101 servos are powered from the 12V SUPPLY; USB carries only DATA. Unplug the cable and /dev/ttyACM* disappears \u2014 the host has NO channel to the arm, every torque-release write fails, and the motors keep holding their last commanded goal. No torque_guard, no finally, no context manager can release torque over a bus that no longer exists.\n\nWHAT THE GUARD *CAN* COVER (all real, all fixed by PR #38): every abnormal exit where the PROCESS STILL HAS A BUS \u2014 SIGINT/Ctrl-C, any unhandled Python exception, and the ACTUAL #33 incident (a second process opening the port; the error was `device reports readiness to read but returned no data (multiple access on port?)` \u2014 the port is CONTENDED but still ours, so the release lands).\n\nThe only thing that could close the yanked-cable hole is a SERVO-SIDE watchdog (the STS3215 dropping torque by itself on comms loss). Whether one exists is UNKNOWN and is filed as issue #37 \u2014 it pairs with the offset-register spike (plan task t5) since both interrogate the same memory table. CAVEAT for that investigation: a watchdog that drops torque during a LEGITIMATE hold would break gentle_move`s stop-and-hold gripper contract, so any timeout must exceed any real hold or it is unusable.\n\nGENERAL LESSON: before writing a testable acceptance criterion, check it is PHYSICALLY achievable. I asserted this one and the user confirmed it on my word; it would have cost a hardware session to discover.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "spec-review", "area": "arm101/hardware/safety.py", "issue": "37", "severity": "known-limit"}, "created": "2026-07-12T08:50:35.973210+00:00", "last_recall": null, "recall_count": 0, "links": ["torque-release-must-use-clear-overload", "motion-verb-crash-leaves-motors-energized"], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "sts3215-offset-register-facts", "hash": "6cc56871bfa8a793c4ac8db344448990cfcacad772a1faa065b3c5ecf0e14bc8", "content": "STS3215 ENCODER-OFFSET REGISTER \u2014 verified facts (t5 spike, 2026-07-12, triple-sourced: Feetech SMS_STS.h/.cpp, Feetech FTServo_Python, LeRobot tables.py/feetech.py/encoding_utils.py). Register `Ofs` (LeRobot: Homing_Offset): ADDRESS 31 (OFS_L; 32 = OFS_H), 2 BYTES, encoding SIGN-MAGNITUDE WITH SIGN ON BIT 11 \u2014 NOT twos complement (wire = (sign<<11)|magnitude; -1073 -> 3121, +1073 -> 1073). RANGE +/-2047 (max magnitude (1<<11)-1). EEPROM (persistent) \u2014 sits under //---EEPROM--- in SMS_STS.h; SRAM starts at addr 40. Therefore needs the addr-55 Lock unlock->write->relock dance (same region as ID/Baud \u2014 the exact registers PR #21 proved silently revert without it). EFFECT: Present_Position = Actual_Position - Homing_Offset.\n\nRANGE MATH for elbow_flex: its unreachable arc spans raw (126, 2020); the offset must land INSIDE that arc; midpoint H = 1073, |1073| <= 2047 with 974 ticks headroom. KEY INSIGHT: the required offset is bounded by WHERE THE UNREACHABLE ARC SITS, not by the travel length \u2014 which is why +/-2047 clears a ~2020-tick travel comfortably. LeRobot half-turn rule independently gives 1074.\n\nSECOND MECHANISM: writing 128 to Torque_Enable (addr 40) triggers Feetech firmware midpoint calibration (CalibrationOfs). Needs no wrap-aware move \u2014 human hand-parks the joint, torque off, one byte \u2014 sidestepping the chicken-and-egg that you cannot COMMAND a move across a seam you have not fixed yet.\n\nTRAP FOR ANY IMPLEMENTER: do NOT copy LeRobot write_calibration wholesale \u2014 it ALSO writes Min/Max_Position_Limit (addrs 9/11), which in servo mode CLAMP goals and would narrow the very reachable set you are recovering. Write ONLY addr 31.\n\nUNRESOLVED CAVEAT (the whole ballgame): it is UNDOCUMENTED whether the firmware computes Present = (raw - Ofs) MOD 4096 (seam RELOCATES -> re-zero works) or plain signed subtraction (seam STAYS PINNED at the physical angle where the magnet rolls over -> re-zero achieves NOTHING). Present_Position is itself sign-magnitude on bit 15 in both Feetech and LeRobot, so negatives ARE representable. Evidence favours modular (LeRobot SO-101 calibration = literally this fix; community says centring PREVENTS overflow) but is circumstantial. SETTLED ONLY BY A HAND-SWEEP with torque off, checking monotonicity \u2014 reading the offset back proves it was APPLIED, NOT that the seam MOVED.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "reference", "record_metadata": {"source": "spike", "area": "arm101/hardware/bus.py", "issue": "35", "pr": "40", "spike": "docs/spikes/sts3215-offset-register.md"}, "created": "2026-07-12T10:24:51.133820+00:00", "last_recall": null, "recall_count": 0, "links": ["encoder-wrap-two-joints", "wrist-roll-cannot-be-re-zeroed", "arm-alive-eeprom-lock-fix"], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "seam-verification-false-pass-traps", "hash": "fb45432f219a4ecc4067c26ada3e28d37e262c1043db183481fda358c28356a6", "content": "TWO TRAPS THAT WOULD PRODUCE A FALSE PASS when verifying an encoder re-zero (found building t8, PR #40, 2026-07-12). Both matter because the verification is the ONLY thing standing between us and shipping a re-zero that silently did nothing.\n\nTRAP 1 \u2014 THE DISCONTINUITY THRESHOLD MUST BE ~500, NOT THE OBVIOUS 2048. The intuition is `a seam jump is ~4096 ticks, so anything over half that (2048) is a wrap`. WRONG: if the firmware does a plain signed subtraction AND read_position`s `& 0x0FFF` mask folds the resulting negative back into range, the observed seam jump is only ~1949 ticks. A 2048 threshold sails straight past it and reports PASS on a broken fix. Use 500 (sensor noise is a few ticks; there is a huge gap between noise and any real discontinuity).\n\nTRAP 2 \u2014 A SHORT CLEAN SWEEP IS `INCONCLUSIVE`, NEVER A PASS. A hand-sweep covering 200 of 2202 ticks that saw no discontinuity has proven NOTHING \u2014 the seam simply was not in the swept region. Claiming a pass from it closes an open question with a lie. Require >=80% travel coverage before `no discontinuity found` is allowed to mean anything.\n\nGENERAL LESSON: when a verification exists to settle an unproven premise, enumerate how it could report SUCCESS while the premise is false. Both traps here are of that shape.\n\nRELATED DESIGN CALL THAT PAID OFF: rather than assume the favourable semantics, FakeBus models BOTH via `offset_wraps` (True = seam relocates, the premise; False = seam stays pinned, the pessimistic reading). The verb is tested against each, so if hardware settles it the wrong way the pessimistic behaviour is already built and tested, not discovered in production.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "build", "area": "arm101/hardware/rezero.py", "issue": "35", "pr": "40"}, "created": "2026-07-12T10:24:51.190254+00:00", "last_recall": null, "recall_count": 0, "links": ["sts3215-offset-register-facts", "wrist-roll-cannot-be-re-zeroed"], "supersedes": null, "lifecycle": "active", "added_by": null}} +{"id": "arm-explore-rebuild-state-2026-07-12", "hash": "fd81c2c306a1f6a022a691980850c13e8f68535bcafcbaf93c7db3f0c6c2b7b2", "content": "STATE OF THE arm explore REBUILD (2026-07-12, main @ v0.21.0, 1202 tests). Three PRs merged today off the converged spec docs/specs/2026-07-12-arm-explore-now-maps-the-arm-s-real-joint-space-it.md and its 21-task plan:\n\n- PR #38 (v0.19.x) \u2014 CLOSES #33. torque_guard: motion verbs release the arm on any ABNORMAL exit (exception/bus fault/SIGINT). HOLD ON SUCCESS, RELEASE ON ABNORMAL (clean exit = ZERO release writes, so gentle_move stop-and-hold survives).\n- PR #39 (v0.20.x) \u2014 `arm profile `: ramps Goal_Speed and finds the highest speed at which CONTACT DETECTION STILL WORKS (not merely the highest the servo survives). Structurally refuses to certify a speed on free motion alone.\n- PR #40 (v0.21.0) \u2014 encoder linearity (#35 code, NOT yet hardware-verified): `arm rezero` + --verify, offset primitives (addr 31), wrist_roll soft limit + enforcement, and the t5 spike doc.\n\nSTILL OPEN AND WHY:\n- #35 stays OPEN deliberately \u2014 the code shipped but the SEAM-EVICTION PROOF has not run on hardware. docs/hardware-rezero-procedure.md is the procedure; step 6 (torque-off hand-sweep of elbow_flex through its full travel, asserting monotonic position) is what settles whether the servo actually MOVES the seam. If it reports `seam-not-evicted`, the re-zero achieves nothing and wave 2a returns to the user for a re-decision (the ARCS fallback was explicitly NOT chosen).\n- #34 (grid) \u2014 t16 (per-joint bucket sizes from span + target bucket COUNT) is BUILT on branch feat/explore-grid, NOT yet PR`d. t17 (bounds from a supplied map, EEPROM only as fallback) and t18 (honest probe-cost budget + pre-flight wall-clock estimate) are NOT built and are BLOCKED on t15 \u2014 the hardware run of `arm profile` that yields real per-joint speeds.\n- #37 \u2014 does the STS3215 have a comms-loss torque watchdog? Filed because a physical USB yank CANNOT be recovered in software (12V powers the servos; USB is only data).\n\nHUMAN-GATED HARDWARE STILL OWED: t4 (SIGINT + port-contention safety acceptance), t6 (re-confirm elbow_flex wrap BEFORE re-zeroing destroys the evidence), t5-hardware/t11 (the rezero power-cycle + seam sweep), t12/t15 (baseline timing + the arm profile run).", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "session", "area": "arm101", "version": "0.21.0", "issues": "33,34,35,37"}, "created": "2026-07-12T10:51:26.916283+00:00", "last_recall": null, "recall_count": 0, "links": ["wrist-roll-cannot-be-re-zeroed", "sts3215-offset-register-facts", "seam-verification-false-pass-traps", "torque-release-must-use-clear-overload", "usb-yank-cannot-be-fixed-in-software"], "supersedes": null, "lifecycle": "active", "added_by": null}} +{"id": "rezero-proven-on-hardware-2026-07-12", "hash": "44eb11b90e77d60071dd52a29fc154808c74a7f85aac3bf462ae0be28e1a2703", "content": "SETTLED ON HARDWARE (2026-07-12, follower /dev/ttyACM1, human present): THE ENCODER RE-ZERO WORKS, and the spike`s open caveat is CLOSED BY MEASUREMENT.\n\nTHE BIG RESULT \u2014 the STS3215 DOES reduce the corrected position MODULO 4096, so the offset genuinely RELOCATES the seam (it does not merely relabel positions). Proven by a torque-off hand sweep of elbow_flex, before/after, same joint same motion:\n Ofs=0 -> monotonic FALSE, 1 discontinuity, span 4093 (the wrap, captured)\n Ofs=1073 -> monotonic TRUE, 0 discontinuities, span 2196, largest jump 28 ticks\nIssue #35 is FIXED for elbow_flex on the physical arm. The pessimistic reading (plain signed subtraction, seam stays pinned) is REFUTED.\n\nFACTORY OFFSET IS 85, NOT 0. All six servos ship with Ofs=+85 at addr 31 (uniform across six joints => a vendor default, not a calibration). The t5 spike ASSUMED factory 0. CONSEQUENCE: every tick ever recorded on this arm was measured in the 85-shifted frame. This is WHY elbow_flex wraps \u2014 the reported seam sits where Actual==Ofs, i.e. at raw 85, which is BELOW the unreachable arc`s lower edge (raw 211) and therefore INSIDE the joint`s travel.\n\nSEMANTICS CONFIRMED TWICE, reversibly: writing Ofs 85->185 dropped reported position by EXACTLY 100; zeroing 85->0 raised it by EXACTLY 85. So Present_Position = Actual - Ofs.\n\nELBOW_FLEX`S FAR WALL, MEASURED FOR THE FIRST TIME (nothing could see across the seam before): travel spans 2196 ticks, reading 1034..3230 in the corrected frame at Ofs=1073. Converting to RAW (raw = corrected + 1073 mod 4096): raw travel = [2107,4095] U [0,207], so the TRUE RAW UNREACHABLE ARC = (207, 2107), width 1900, midpoint 1157. The shipped table said (126,2020)/midpoint 1073 \u2014 those were REPORTED-frame ticks in the 85 frame. 1073 lands inside (207,2107) with 866/1034 margin, so the shipped code WORKED BY LUCK, not by construction.\n\nALSO VALIDATED LIVE: PR #38`s torque_guard fired on real hardware \u2014 a failing `arm rezero` released motor 3 on its way out (`Torque released on motors 3 after an abnormal exit`). And t8`s false-pass guards rejected THREE sweeps as INCONCLUSIVE (0, 0 and 376 ticks of coverage) before the real one \u2014 a naive `no discontinuity seen => PASS` would have declared victory on an empty sweep with the arm untouched.\n\nSTILL UNPROVEN: PERSISTENCE. The offset reads back as 1073 but has NOT survived a power cycle yet \u2014 PR #21 exists because exactly this read back fine and reverted on power-up. Cut and restore the 12V BUS POWER (not USB) and re-read addr 31.\n\nGOTCHA: a read issued immediately after an EEPROM write can return GARBAGE \u2014 a read_position ~0.2s after write_offset returned 0 while the servo genuinely held 3387 (re-reads were stable and correct). A plausible-looking wrong value is worse than an error.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "hardware-run", "area": "arm101/hardware/rezero.py", "issue": "35", "port": "/dev/ttyACM1", "version": "0.21.0", "status": "seam-evicted-persistence-pending"}, "created": "2026-07-12T12:01:22.414446+00:00", "last_recall": null, "recall_count": 0, "links": ["sts3215-offset-register-facts", "seam-verification-false-pass-traps", "wrist-roll-cannot-be-re-zeroed", "encoder-wrap-two-joints"], "supersedes": null, "lifecycle": "active", "added_by": null}} diff --git a/arm101/hardware/arm_spec.py b/arm101/hardware/arm_spec.py index 988cc81..43073ef 100644 --- a/arm101/hardware/arm_spec.py +++ b/arm101/hardware/arm_spec.py @@ -898,13 +898,69 @@ def _offset_for_seam_at(tick: int) -> int: #: measured on is NOT re-written to 1157: it holds 1073, which #: :meth:`UnreachableArc.evicts` — the seam is out of the travel, the axis is #: linear, and the job is done. See :func:`rezero_offset`. +#: The furthest into the LOW band ``elbow_flex`` was ever observed (raw ticks), +#: across every hardware run on 2026-07-12. Deliberately the MAXIMUM seen, not the +#: last seen: one sweep stopped at 206, but the joint later came to rest at 218, +#: and an arc edge set at 206 promptly false-refused. Take the furthest. +_LOW_WALL_OBSERVED = 218 + +#: The furthest into the HIGH band ``elbow_flex`` was ever observed (raw ticks). +#: Same rule — the smallest high-band tick seen across all runs (2107; a later run +#: only reached 2118), because that is the deepest the joint actually got. +_HIGH_WALL_OBSERVED = 2107 + +#: Inset applied to EACH side of the measured envelope before declaring the arc. +#: +#: A hand-found wall is not a crisp number (206..218 depending on push force), and +#: at least one of these limits may be the TABLE rather than the joint's mechanical +#: stop — the operator had to move the gripper aside "or we would hit the table". +#: An environmental wall makes the true travel WIDER than measured, so the true +#: unreachable arc is NARROWER. Insetting is conservative against both. +#: +#: The cost is nothing: the arc must merely CONTAIN the seam — a single tick would +#: suffice — and after the inset it still spans ~1700. +_ARC_MARGIN_TICKS = 100 + REZERO_ARCS: dict[str, UnreachableArc] = { - # RAW ticks. Hardware sweep, follower, 2026-07-12: travel 1034..3230 reported - # at Ofs=1073 (span 2196, monotonic, 0 discontinuities) -> raw [2107, 4095] ∪ - # [0, 207] -> unreachable (207, 2107). The far wall, measured for the first - # time. The previous (126, 2020) were REPORTED-frame ticks read at the factory - # Ofs=85 and used as if they were raw. - "elbow_flex": UnreachableArc(low=207, high=2107), + # RAW ticks, hardware-measured on the follower, 2026-07-12, with a DELIBERATE + # MARGIN. Read the margin note below before "tightening" this to the measured + # numbers — the tight version is what broke. + # + # WIDEST REACHABLE ENVELOPE observed across every run that day (take the + # furthest point ever seen on each side, not the last one): + # raw reachable = [2107, 4095] ∪ [0, 218] + # => the true unreachable arc is AT MOST (218, 2107) + # + # A first cut declared exactly (207, 2107) — the extremes of one sweep — and + # it FALSE-REFUSED within minutes: the joint came to rest at raw 218, eleven + # ticks past an edge taken from a sweep the operator had simply stopped short + # of, and `arm rezero` correctly reported that the joint "cannot be where it + # says it is". A wall is not a crisp number: it moved 206..218 depending on + # how hard a human pushed. An arc set AT a measured extreme is an arc that + # contradicts the arm the first time someone pushes harder. + # + # So the declared arc is a STRICT SUBSET of the unreachable region, inset by + # _ARC_MARGIN_TICKS on each side. Shrinking is conservative in BOTH directions + # that matter: it cannot false-refuse a legal position, and it cannot claim a + # tick the joint can actually reach. + # + # THE SECOND REASON FOR MARGIN, and the deeper one. These walls were found by + # hand, and the operator had to move the GRIPPER out of the way "or we would + # hit the table". So at least one limit may be ENVIRONMENTAL (the table) rather + # than MECHANICAL (the joint's own stop) — and an environmental wall makes the + # true travel WIDER than measured, hence the true unreachable arc NARROWER than + # measured. That is exactly the failure issue #34 is about: the table is the + # wall, and the table is not in the servo's EEPROM. Margin absorbs it. + # + # What keeps this honest rather than hopeful: the seam sits ~855 ticks (~75deg) + # from the nearest wall ever observed, and the acceptance sweep ran 2196 ticks + # MONOTONIC with 0 discontinuities — it would have SHOWN a seam crossing had + # raw 1073 been reachable. The arc only has to CONTAIN the seam; one tick would + # do, and it keeps ~1700. + "elbow_flex": UnreachableArc( + low=_LOW_WALL_OBSERVED + _ARC_MARGIN_TICKS, # 218 + 100 = 318 + high=_HIGH_WALL_OBSERVED - _ARC_MARGIN_TICKS, # 2107 - 100 = 2007 + ), } diff --git a/docs/hardware-rezero-run-2026-07-12.md b/docs/hardware-rezero-run-2026-07-12.md new file mode 100644 index 0000000..b0dce27 --- /dev/null +++ b/docs/hardware-rezero-run-2026-07-12.md @@ -0,0 +1,113 @@ +# Hardware run-log — encoder re-zero on the follower (2026-07-12) + +Arm: SO-101 follower, `/dev/ttyACM1`, bolted to a table. Operator present throughout; +every joint hand-moved by a human. arm101-cli **v0.21.0**. + +**Headline: the re-zero works.** The STS3215 reduces the corrected position **modulo +4096**, so a homing offset genuinely *relocates* the encoder seam rather than merely +relabelling positions. That was the one unproven assumption the whole of issue #35 rested +on ([the t5 spike](spikes/sts3215-offset-register.md) §4 called it GO-**WITH-CAVEAT**), and +it is now settled by measurement rather than inference. + +## The result, before and after + +The same joint, swept the same way by hand, torque off: + +| | offset in force | monotonic | discontinuities | span | +|---|---|---|---|---| +| **before** | 0 | **False** | **1** | 4093 ticks | +| **after** | 1073 | **True** | **0** | 2196 ticks | + +Largest single-sample jump after the re-zero: **28 ticks** — noise, against a 500-tick +discontinuity threshold. `elbow_flex` no longer wraps within its travel. + +## The finding that mattered: the servos are not at factory zero + +**All six servos ship with `Ofs = +85`** (EEPROM addr 31). Uniform across six joints, so it +is a vendor default, not a per-joint calibration. The spike assumed a factory `0`. + +This is not a footnote — **it is the mechanism of the bug**. The reported seam sits where +`Actual == Ofs`, i.e. at **raw 85**. `elbow_flex`'s unreachable arc starts at **raw 207**. +So the seam sat *below* the arc — **inside the joint's travel**. That is exactly why +`elbow_flex` wrapped. + +The register semantics were confirmed twice, reversibly, before anything was committed: + +- `Ofs 85 → 185` dropped the reported position by **exactly 100** +- `Ofs 85 → 0` raised it by **exactly 85** + +So `Present_Position = Actual − Ofs`, on this firmware, measured. + +## `elbow_flex`'s far wall — measured for the first time + +Nothing could see across the seam before, so the arc table carried only a *lower bound*. +The passing sweep measured the real thing: + +- travel: **1034 .. 3230** in the corrected frame (`Ofs=1073`) — span **2196 ticks** +- in raw ticks: reachable = `[2107, 4095] ∪ [0, 207]` +- therefore the **true raw unreachable arc = (207, 2107)**, width 1900, midpoint **1157** + +The shipped table said `(126, 2020)`, midpoint 1073 — but those were **reported**-frame +ticks recorded in the `Ofs=85` frame, used as if they were raw. The written offset of 1073 +does land inside `(207, 2107)`, with 866/1034 ticks of margin — so **the shipped code +worked, but by luck rather than by construction**. Fixed separately; see the defects below. + +## What the run validated beyond the re-zero itself + +**PR #38's torque guard, on real hardware.** The first `arm rezero` attempt failed (the +frame guard, correctly), and the CLI printed: + +```text +Torque released on motors 3 after an abnormal exit. +``` + +The arm was de-energised on the way out instead of left holding. Issue #33's fix, observed +on the physical arm rather than in a test. + +**The false-pass guards earned their keep.** Three sweeps were rejected as `INCONCLUSIVE` +before the real one — coverage of 0, 0 and 376 ticks against an expected ~2202. A naive +"no discontinuity seen ⇒ PASS" would have declared victory on the very first *empty* sweep, +with the arm never touched. The ≥80%-coverage rule is what stopped it. + +## Defects this run exposed + +1. **The unreachable arc was in the wrong frame.** `REZERO_ARCS` held reported-frame ticks + and used them as raw. Worked here only because the arc is 1900 ticks wide. +2. **The `current_offset in (0, target)` guard was too strict.** It refused outright on a + servo holding the factory `85` — i.e. on *every fresh SO-101*. Now that + `raw = (reported + offset) mod 4096` is known, any readable frame can be converted. +3. **A dropped packet raised `IndexError`, not `CliError`.** The vendor SDK indexes + `data[1]` on a short packet while `result == COMM_SUCCESS`, so a raw traceback escaped — + violating the repo's "no traceback ever leaks" contract. Hit live on a healthy bus. +4. **A read immediately after an EEPROM write can return garbage.** A `read_position` ~0.2 s + after `write_offset` returned **0** while the servo genuinely held 3387 (re-reads were + stable and correct). A plausible-looking wrong value is far more dangerous than an error. + +## Persistence — CONFIRMED + +The 12 V bus power was cut and restored, and the offset was re-read **cold**: + +```text +| elbow_flex | 3 | ok | 3241 | ... | offset 1073 | +``` + +**It survived.** The other five joints still read 85, untouched. This closes the last open +risk on the re-zero: PR #21 exists precisely because an EEPROM write on these servos once +read back perfectly and then silently reverted on the next power-up when the Lock register +was mishandled. The unlock → write → re-lock dance held. + +Both halves of issue #35 are therefore settled for `elbow_flex` **on hardware**: the seam is +evicted (proven by a hand sweep) *and* the fix persists across a power cycle (proven cold). + +## Current state of the arm + +| joint | motor | offset | note | +|---|---|---|---| +| shoulder_pan | 1 | 85 | untouched (factory) | +| shoulder_lift | 2 | 85 | untouched (factory) | +| **elbow_flex** | **3** | **1073** | **re-zeroed — seam evicted, verified by sweep** | +| wrist_flex | 4 | 85 | untouched (factory) | +| wrist_roll | 5 | 85 | untouched — handled by a soft limit, not a re-zero | +| gripper | 6 | 85 | untouched (factory) | + +Every joint limp (torque 0), EEPROM re-locked on every write. From 53396f2145b103d6e81a2c76586959f44a83a457 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sun, 12 Jul 2026 16:04:00 +0300 Subject: [PATCH 4/7] test(rezero): derive expectations from the arc table, not hard-coded ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 34 failing tests were not failing because the arc changed. They were failing because they had copied it. `REZERO_ARCS["elbow_flex"]` was re-derived on hardware today and given a deliberate margin — (207, 2107) -> (318, 2007), midpoint 1157 -> 1162 — because the first cut declared the arc AT the extremes of a single hand sweep and FALSE-REFUSED within minutes: the joint came to rest at raw 218, eleven ticks past an edge taken from a sweep the operator had simply stopped short of, and `arm rezero` correctly reported that the joint "cannot be where it says it is". A hand-found wall is not a crisp number (206..218 depending on push force), and at least one wall may be the TABLE rather than the joint's own mechanical stop. So the arc is a measurement that WILL be taken again — and re-taking it must cost ONE table edit. It cannot cost 34 broken tests every time, or the arm's own measurements start losing arguments with the test suite. Replaces ~125 arc-coupled literals across test_rezero.py and test_arm_rezero_cli.py with values derived from arm_spec: the target from `rezero_offset()` / `UnreachableArc.midpoint`, the bounds from `.low` / `.high`, the travel from `.travel_ticks`, the walls and inset from `_LOW_WALL_OBSERVED` / `_HIGH_WALL_OBSERVED` / `_ARC_MARGIN_TICKS`. Positions inside/outside the arc and "what the servo will then report" are computed (`reported_at`, `_full_sweep`), never baked in — including the sweep LENGTHS, which must scale with the travel or a re-measured arc turns a passing sweep into a silent INCONCLUSIVE. Kept literal on purpose, because each is a fact rather than a derived quantity: 85 (the factory offset every SO-101 ships with), 1073 (what our follower actually holds — it evicts the seam, so a re-zero is a NO-OP), 126 (where elbow_flex rests), 4096 (the encoder modulus), and the 2026-07-12 sweep's own readings (1034/3230 reported at Ofs=1073 -> raw 2107/207). No assertion was weakened, skipped or deleted, and no production code was touched. Two assertions were ADDED where derivation exposed a claim worth pinning: the arc may never contain a tick the joint has actually been seen at (the false-refusal, stated as an invariant), and the raw 4095->0 roll-over becomes a ONE-TICK step (issue #35's deliverable, previously only implied across parametrize rows). `test_OUR_ARM_at_1073_is_a_clean_NO_OP_ not_a_rewrite_to_1157` is renamed to `..._not_a_rewrite_to_the_arcs_midpoint` — it named a number that went stale the same day, and the claim was never about the number. Acid test: setting _ARC_MARGIN_TICKS to 150 (and, separately, to 300, and re-measuring BOTH walls so the midpoint moves 1162 -> 1195) leaves all 1242 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6 --- tests/test_arm_rezero_cli.py | 129 +++++---- tests/test_bus_offset.py | 11 +- tests/test_rezero.py | 534 ++++++++++++++++++++++++----------- 3 files changed, 445 insertions(+), 229 deletions(-) diff --git a/tests/test_arm_rezero_cli.py b/tests/test_arm_rezero_cli.py index 6d11990..41122e6 100644 --- a/tests/test_arm_rezero_cli.py +++ b/tests/test_arm_rezero_cli.py @@ -17,6 +17,11 @@ under it the re-zero achieves nothing. The verb must say so, loudly, with a non-zero exit — that failure is what stands between the operator and shipping on top of a fix that did nothing. + +Every tick here comes from ``tests.test_rezero``, which derives them all from +``arm_spec.REZERO_ARCS`` — see that module's header. The arc is a hardware +measurement that WILL be taken again, and re-taking it must cost one table edit, +not a day spent re-typing ticks into two test files. """ from __future__ import annotations @@ -37,9 +42,14 @@ ELBOW, ELBOW_MOTOR, EXPECTED_OFFSET, + EXPECTED_TRAVEL, FACTORY_OFFSET, + IMPOSSIBLE_RAW, + ODD_EVICTING_OFFSET, OUR_ARM_OFFSET, + REST_RAW, HandMovedBus, + reported_at, ) #: STS3215 Goal_Position — the register this verb must never, ever write. @@ -91,12 +101,17 @@ def _args( ) -#: A sweep short enough to run instantly against a FakeBus (which needs no -#: pacing) but long enough to walk the hand-moved shaft across the seam: -#: 2.5s / 0.05s = 50 samples x 50 ticks = 2500 raw ticks, comfortably past the -#: 2196-tick travel. -SWEEP_SECONDS = 2.5 -SWEEP_STEP = 50 +#: A sweep short enough to run instantly against a FakeBus (which needs no pacing) +#: but long enough to walk the hand-moved shaft the WHOLE travel and across the +#: seam. Both numbers are derived from the arc: a re-measured arc changes the +#: travel, and a duration typed as a literal would quietly stop covering it (a +#: sweep that falls under MIN_COVERAGE is INCONCLUSIVE, not a pass — so this would +#: fail as a mystery, not as an arithmetic error). +SWEEP_STEP = 50 # raw ticks the simulated hand advances between polls +_SWEEP_SAMPLES = EXPECTED_TRAVEL // SWEEP_STEP + 2 # +1 fencepost, +1 past the far wall +#: ...as a wall-clock duration, with half an interval of slack so that +#: ``samples_for``'s ``int(duration / interval)`` cannot land a poll short. +SWEEP_SECONDS = (_SWEEP_SAMPLES + 0.5) * rezero.DEFAULT_SWEEP_INTERVAL def _swept_bus(*, offset_wraps: bool = True, rezeroed: bool = True) -> HandMovedBus: @@ -212,7 +227,7 @@ def _explode(_port): # pragma: no cover - must never be reached out = capsys.readouterr().out assert "Dry-run plan: arm rezero elbow_flex" in out - assert "addr=31, value=1157" in out # the exact wire value + assert f"addr=31, value={EXPECTED_OFFSET}" in out # the exact wire value assert "addr=55, value=0" in out and "addr=55, value=1" in out # the Lock dance assert "COMMANDS NO MOTION" in out assert "no goal position is ever written" in out @@ -231,7 +246,7 @@ def test_dry_run_json_carries_the_whole_plan(monkeypatch, capsys): assert plan["wire_value"] == EXPECTED_OFFSET assert plan["unreachable_arc"] == [ARC_LOW, ARC_HIGH] assert plan["seam_moves_to_raw_tick"] == EXPECTED_OFFSET - assert plan["expected_travel_ticks"] == 2196 + assert plan["expected_travel_ticks"] == EXPECTED_TRAVEL assert plan["mode"] == "write" @@ -277,7 +292,7 @@ def test_verify_dry_run_warns_that_the_arm_will_SAG(monkeypatch, capsys): def test_tty_prompt_confirms_before_the_write(monkeypatch, capsys): - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin(["yes\n"], tty=True)) @@ -289,7 +304,7 @@ def test_tty_prompt_confirms_before_the_write(monkeypatch, capsys): def test_tty_prompt_declined_writes_NOTHING(monkeypatch, capsys): - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin(["no\n"], tty=True)) @@ -300,7 +315,7 @@ def test_tty_prompt_declined_writes_NOTHING(monkeypatch, capsys): def test_non_tty_without_apply_never_writes(monkeypatch): - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -315,7 +330,7 @@ def test_non_tty_without_apply_never_writes(monkeypatch): def test_apply_writes_the_offset_and_reports_the_read_back(monkeypatch, capsys): - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -324,8 +339,8 @@ def test_apply_writes_the_offset_and_reports_the_read_back(monkeypatch, capsys): payload = json.loads(capsys.readouterr().out) assert payload["read_back_offset"] == EXPECTED_OFFSET assert payload["applied"] is True - assert payload["plan"]["raw_position"] == 126 - assert payload["shift"]["observed_position"] == 3065 # (126 − 1157) mod 4096 + assert payload["plan"]["raw_position"] == REST_RAW + assert payload["shift"]["observed_position"] == reported_at(REST_RAW) # (rest − target) assert payload["shift"]["as_predicted"] is True # Applied is not persistent, and applied is not evicted. Neither is claimed. assert payload["persistence_proven"] is False @@ -343,7 +358,7 @@ def test_a_FACTORY_FRESH_ARM_at_offset_85_can_finally_BE_RE_ZEROED(monkeypatch, Now it reads 85, converts out of it, finds the shaft at raw 126, and writes. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -351,9 +366,9 @@ def test_a_FACTORY_FRESH_ARM_at_offset_85_can_finally_BE_RE_ZEROED(monkeypatch, payload = json.loads(capsys.readouterr().out) assert payload["plan"]["current_offset"] == FACTORY_OFFSET - assert payload["plan"]["current_seam_tick"] == 85 # inside the [0, 207] band: the bug - assert payload["plan"]["reported_position"] == 41 # 126 − 85 — a REPORTED tick - assert payload["plan"]["raw_position"] == 126 # ...and the raw one behind it + assert payload["plan"]["current_seam_tick"] == FACTORY_OFFSET # in the travel: the bug + assert payload["plan"]["reported_position"] == REST_RAW - FACTORY_OFFSET == 41 + assert payload["plan"]["raw_position"] == REST_RAW # ...and the raw one behind it assert payload["plan"]["already_applied"] is False assert payload["read_back_offset"] == EXPECTED_OFFSET assert payload["applied"] is True @@ -367,7 +382,7 @@ def test_the_write_path_COMMANDS_NO_MOTION(monkeypatch): round, through its whole travel, into a wall. Asserted here on the CLI verb's complete write surface — not just on the primitive it calls. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -388,7 +403,7 @@ def test_the_result_TELLS_the_operator_to_power_cycle(monkeypatch, capsys): PERSISTS. A result that ended on a success line would let the operator walk away with neither fact established. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -403,7 +418,7 @@ def test_the_result_TELLS_the_operator_to_power_cycle(monkeypatch, capsys): def test_a_second_run_on_an_already_re_zeroed_joint_writes_nothing(monkeypatch, capsys): """The procedure sends the operator away to power-cycle. Coming back is normal.""" - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: EXPECTED_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: EXPECTED_OFFSET}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -415,18 +430,23 @@ def test_a_second_run_on_an_already_re_zeroed_joint_writes_nothing(monkeypatch, assert bus.register_writes == [] -def test_OUR_ARM_at_1073_is_a_clean_NO_OP_not_a_rewrite_to_1157(monkeypatch, capsys): +def test_OUR_ARM_at_1073_is_a_clean_NO_OP_not_a_rewrite_to_the_arcs_midpoint(monkeypatch, capsys): """The arm on the bench, and the reason "already-applied" is not "offset == target". Our follower holds 1073 — written by the first, frame-confused re-zero. Its - seam sits at raw 1073, strictly inside the unreachable (207, 2107), and a hand - sweep proved its travel continuous. **It is fixed.** ``arm rezero elbow_flex - --apply`` on it must therefore write NOTHING: rewriting a working calibration - to 1157 would spend an EEPROM write on a finite-write part to slide a seam - from one tick the joint can never reach to another tick the joint can never - reach. Cosmetic centring is not worth a write. + seam sits at raw 1073, strictly inside the unreachable arc, and a hand sweep + proved its travel continuous. **It is fixed.** ``arm rezero elbow_flex --apply`` + on it must therefore write NOTHING: rewriting a working calibration to the arc's + midpoint would spend an EEPROM write on a finite-write part to slide a seam from + one tick the joint can never reach to another tick the joint can never reach. + Cosmetic centring is not worth a write. + + (The test used to name that midpoint — 1157 — in its own title. The arc has been + re-measured since, the midpoint moved, and the title went stale within the day. + The claim was never about a number: it is "not a rewrite to *the midpoint*", + whatever the midpoint currently is.) """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -437,8 +457,9 @@ def test_OUR_ARM_at_1073_is_a_clean_NO_OP_not_a_rewrite_to_1157(monkeypatch, cap assert payload["reason"] == "already-applied" assert payload["plan"]["already_applied"] is True assert payload["plan"]["current_offset"] == OUR_ARM_OFFSET - assert payload["plan"]["current_seam_tick"] == 1073 - assert payload["plan"]["target_offset"] == OUR_ARM_OFFSET # NOT 1157 + assert payload["plan"]["current_seam_tick"] == OUR_ARM_OFFSET + assert payload["plan"]["target_offset"] == OUR_ARM_OFFSET # NOT the midpoint + assert payload["plan"]["target_offset"] != EXPECTED_OFFSET # ...which really differs # It still TELLS you what a fresh re-zero would have written — it just doesn't. assert payload["fresh_rezero_would_write"] == EXPECTED_OFFSET assert payload["unreachable_arc"] == [ARC_LOW, ARC_HIGH] @@ -451,7 +472,7 @@ def test_OUR_ARM_at_1073_is_a_clean_NO_OP_not_a_rewrite_to_1157(monkeypatch, cap def test_the_no_op_report_EXPLAINS_itself_in_text_mode(monkeypatch, capsys): """An operator who ran --apply and saw nothing happen deserves to know why.""" - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -459,10 +480,10 @@ def test_the_no_op_report_EXPLAINS_itself_in_text_mode(monkeypatch, capsys): out = capsys.readouterr().out assert "already re-zeroed" in out - assert "raw tick 1073" in out + assert f"raw tick {OUR_ARM_OFFSET}" in out assert f"({ARC_LOW}, {ARC_HIGH})" in out # ...and where that is assert "Nothing written" in out - assert "1157" in out # what a fresh re-zero WOULD write, stated, not hidden + assert str(EXPECTED_OFFSET) in out # what a fresh re-zero WOULD write, not hidden def test_a_write_that_does_not_stick_is_an_ENV_error(monkeypatch, capsys): @@ -474,7 +495,7 @@ class _AmnesiacBus(FakeBus): def read_offset(self, motor: int) -> int: return 0 - bus = _AmnesiacBus(positions={ELBOW_MOTOR: 126}) + bus = _AmnesiacBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -489,13 +510,14 @@ def read_offset(self, motor: int) -> int: def test_the_write_path_WARNS_when_the_position_does_not_move_as_predicted(monkeypatch, capsys): """The free, early probe: under the pessimistic firmware reading it fires at once. - ``offset_wraps=False`` makes the servo report −947 from rest — a value the + ``offset_wraps=False`` makes the servo report ``rest − target`` from rest — a + NEGATIVE value the position register cannot hold. The write itself still succeeded (it read back), so this is a warning and not an error; the STOP verdict belongs to ``--verify``. But the operator is told, 30 seconds before the sweep could tell them. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offset_wraps=False) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offset_wraps=False) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -510,15 +532,15 @@ def test_the_write_path_WARNS_when_the_position_does_not_move_as_predicted(monke def test_a_servo_holding_an_UNFAMILIAR_but_EVICTING_offset_is_a_no_op_not_a_refusal( monkeypatch, capsys ): - """777 is nobody's computed target — and its seam is already out of the travel. + """An offset nobody computed — and its seam is already out of the travel. The old verb refused any offset that was "neither the factory 0 nor this joint's computed 1073", because it could not convert frames and would not - guess. It can convert now, so the question is answerable: raw 777 is strictly - inside (207, 2107), the joint cannot reach it, cannot cross it, and is - therefore already linear. Nothing to do, and nothing to refuse. + guess. It can convert now, so the question is answerable: this seam is strictly + inside the arc, the joint cannot reach it, cannot cross it, and is therefore + already linear. Nothing to do, and nothing to refuse. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: 777}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: ODD_EVICTING_OFFSET}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -526,7 +548,8 @@ def test_a_servo_holding_an_UNFAMILIAR_but_EVICTING_offset_is_a_no_op_not_a_refu payload = json.loads(capsys.readouterr().out) assert payload["reason"] == "already-applied" - assert payload["plan"]["current_seam_tick"] == 777 + assert payload["plan"]["current_seam_tick"] == ODD_EVICTING_OFFSET + assert ODD_EVICTING_OFFSET != EXPECTED_OFFSET # ...and it is nobody's target assert bus.offset_writes == [] @@ -538,7 +561,7 @@ def test_a_servo_whose_seam_is_STILL_IN_ITS_TRAVEL_is_re_zeroed_from_that_frame( So there IS work to do, and the verb must do it from that servo's own frame rather than refusing because the number is unfamiliar. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: -1096}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: -1096}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -546,13 +569,13 @@ def test_a_servo_whose_seam_is_STILL_IN_ITS_TRAVEL_is_re_zeroed_from_that_frame( payload = json.loads(capsys.readouterr().out) assert payload["plan"]["current_seam_tick"] == 3000 - assert payload["plan"]["raw_position"] == 126 # converted out of a NEGATIVE offset + assert payload["plan"]["raw_position"] == REST_RAW # converted out of a NEGATIVE offset assert payload["read_back_offset"] == EXPECTED_OFFSET assert bus.offset_writes == [{"motor": ELBOW_MOTOR, "offset": EXPECTED_OFFSET}] def test_a_joint_reporting_an_impossible_position_is_refused(monkeypatch): - bus = FakeBus(positions={ELBOW_MOTOR: 1500}) # inside its own unreachable arc + bus = FakeBus(positions={ELBOW_MOTOR: IMPOSSIBLE_RAW}) # inside its own unreachable arc _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -612,7 +635,7 @@ def test_a_latched_motor_can_still_be_re_zeroed(monkeypatch, capsys): cleared, the plan is re-read, and the offset is written exactly as it would be on an un-latched motor. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}).fail_with_overload_on_op(1) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}).fail_with_overload_on_op(1) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -634,7 +657,7 @@ def test_the_operator_is_told_the_joint_went_limp_when_a_latch_is_cleared(monkey latch — the operator standing at the arm needs to know THAT happened and WHY, on stderr, before the write proceeds. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}).fail_with_overload_on_op(1) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}).fail_with_overload_on_op(1) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -658,7 +681,7 @@ def test_the_happy_path_issues_NO_recovery_clear_overload_call(monkeypatch): this asserts the total stays at exactly that one call, i.e. the new recovery path around ``plan_rezero`` never fired. """ - bus = _ClearOverloadSpyBus(positions={ELBOW_MOTOR: 126}) + bus = _ClearOverloadSpyBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -678,7 +701,9 @@ def test_the_already_applied_noop_path_does_not_de_energise_the_joint(monkeypatc an operator must not go limp because a re-zero happened to be re-run against it. """ - bus = _ClearOverloadSpyBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: EXPECTED_OFFSET}) + bus = _ClearOverloadSpyBus( + positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: EXPECTED_OFFSET} + ) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -711,7 +736,7 @@ def read_offset(self, motor: int) -> int: message=f"FakeBus: motor {motor} refuses to un-latch.", ) - bus = _StubbornlyLatchedBus(positions={ELBOW_MOTOR: 126}) + bus = _StubbornlyLatchedBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) @@ -926,7 +951,7 @@ def _spy(bus, motors=(), **kwargs): return real_guard(bus, motors, **kwargs) monkeypatch.setattr(arm_cmd, "torque_guard", _spy) - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) _patch_bus(monkeypatch, bus) monkeypatch.setattr(sys, "stdin", _FakeStdin([], tty=False)) diff --git a/tests/test_bus_offset.py b/tests/test_bus_offset.py index f9777a9..265f8c6 100644 --- a/tests/test_bus_offset.py +++ b/tests/test_bus_offset.py @@ -56,10 +56,11 @@ #: A real, seam-evicting offset for elbow_flex — and the one our follower is #: actually carrying. Its seam lands at raw 1073, strictly inside the measured -#: unreachable arc (207, 2107), so it does the job; a *fresh* re-zero would write -#: the arc's midpoint, 1157 (see arm_spec.REZERO_ARCS). Used throughout so the -#: codec tests read as the real scenario. What matters here is the WIRE ENCODING, -#: which is the same shape for either number. +#: unreachable arc, so it does the job; a *fresh* re-zero would write the arc's +#: midpoint instead (see arm_spec.REZERO_ARCS, and tests/test_rezero.py, which +#: derives that midpoint rather than typing it — the arc gets re-measured). +#: Used throughout so the codec tests read as the real scenario. What matters +#: here is the WIRE ENCODING, which is the same shape for either number. ELBOW_FLEX_OFFSET = 1073 #: ``encode_offset(-1073)`` -> ``2048 + 1073``. Spelled out because getting this @@ -663,7 +664,7 @@ def test_fakebus_present_position_shifts_by_the_offset(): ``elbow_flex`` rests at raw 126 (past its wrap). With H = 1073 the servo reports ``(126 - 1073) mod 4096 == 3149`` — and the seam it used to cross mid-travel now sits at raw 1073, strictly inside the arc it cannot reach - (``(207, 2107)``, measured 2026-07-12). Evicted, which is the whole goal. + (``arm_spec.REZERO_ARCS``, measured 2026-07-12). Evicted, which is the goal. """ bus = FakeBus(positions={3: 126}) bus.open() diff --git a/tests/test_rezero.py b/tests/test_rezero.py index 3562e9a..d93f3a7 100644 --- a/tests/test_rezero.py +++ b/tests/test_rezero.py @@ -31,6 +31,25 @@ the REPORTED frame at ``Ofs = 85`` and used as if it were RAW, so the target came out a factory-offset away from where it was meant to be. It landed inside the true arc regardless, by luck. Tests below pin the conversion on every path. + +The FOURTH thing pinned here, and the reason every tick below is *derived*: **the +arc will be re-measured, and re-measuring it must cost ONE table edit.** +------------------------------------------------------------------------------- +The first cut of the raw arc was declared AT the extremes of a single hand sweep +— and it FALSE-REFUSED within minutes. The joint came to rest at raw 218, eleven +ticks past an edge taken from a sweep the operator had simply stopped short of, +and ``arm rezero`` correctly reported that the joint "cannot be where it says it +is". A hand-found wall is not a crisp number (206..218, depending on how hard a +human pushes), and at least one of ``elbow_flex``'s two walls may be the TABLE +rather than the joint's own mechanical stop. So the arc now carries a deliberate +margin — and it will move again, the next time somebody sweeps the joint. + +That makes hard-coding its ticks *here* a bug in its own right: a table that is +expected to be re-measured must not have 52 copies of itself in the test suite. +So every tick below comes out of :data:`arm101.hardware.arm_spec.REZERO_ARCS`, +and the numbers that stay literal stay literal on purpose — a *historical fact* +about a real servo (the factory 85, our follower's 1073), or the encoder's own +geometry (4096). Nothing the arc can move is written down twice. """ from __future__ import annotations @@ -53,13 +72,48 @@ ELBOW = "elbow_flex" ELBOW_MOTOR = 3 -#: The MEASURED raw unreachable arc (hardware sweep, follower, 2026-07-12): the -#: travel is raw [2107, 4095] ∪ [0, 207], so (207, 2107) is what it cannot reach. -ARC_LOW = 207 -ARC_HIGH = 2107 - -#: What a FRESH re-zero writes: the arc's midpoint, (207 + 2107) // 2. -EXPECTED_OFFSET = 1157 +# --- everything from here to FACTORY_OFFSET is DERIVED from the arc table --- + +#: The joint's RAW unreachable arc, straight from ``arm_spec`` — the single +#: source of every tick in this module. Re-measure the walls on hardware, edit +#: ``REZERO_ARCS``, and this suite follows without a line of it changing. +ARC = arm_spec.rezero_arc(ELBOW) +ARC_LOW = ARC.low +ARC_HIGH = ARC.high + +#: What a FRESH re-zero writes: the arc's midpoint. **Derived, never typed** — +#: which is the same discipline ``rezero_offset`` itself follows, and for the +#: same reason (a typed target and a corrected arc drift apart silently). +EXPECTED_OFFSET = arm_spec.rezero_offset(ELBOW) + +#: ``elbow_flex``'s travel: the whole circle minus the arc it cannot reach. +EXPECTED_TRAVEL = ARC.travel_ticks + +#: The raw walls a human hand actually reached, and the inset applied to EACH of +#: them before the arc was declared. The arc *is* these walls, pulled in by the +#: margin — and the margin is what stops a harder push from contradicting the +#: table (which is exactly what the un-inset first cut did). +LOW_WALL = arm_spec._LOW_WALL_OBSERVED +HIGH_WALL = arm_spec._HIGH_WALL_OBSERVED +ARC_MARGIN = arm_spec._ARC_MARGIN_TICKS + +#: A raw tick the joint is supposed to be physically incapable of holding. Dead +#: centre of the arc — which is also, and not by coincidence, where the seam goes: +#: the same tick is "the safest place for the seam" and "the most impossible place +#: for the shaft", because those are the same claim. +IMPOSSIBLE_RAW = ARC.midpoint + +#: An offset nobody would ever compute — and whose seam lands strictly inside the +#: arc anyway, so the joint carrying it is already fixed. Derived (rather than a +#: nice round literal like 500) so that it stays inside a RE-MEASURED arc: a +#: literal here is a literal that eventually falls outside one. +ODD_EVICTING_OFFSET = (ARC_LOW + EXPECTED_OFFSET) // 2 + +# --- literals, and each one is a FACT rather than a derived quantity --- + +#: Where ``elbow_flex`` physically rests: raw ~126, PAST its wrap. A fact about +#: the arm on the bench, not a number the arc can move. +REST_RAW = 126 #: What a factory-fresh STS3215 actually holds. **Not 0.** Measured uniform across #: all six joints of the follower — this is the state of every un-touched SO-101, @@ -67,17 +121,55 @@ FACTORY_OFFSET = 85 #: What OUR follower holds right now, written by the first (frame-confused) -#: re-zero. Its seam sits at raw 1073, which is strictly inside (207, 2107) — so -#: the seam IS evicted and the arm IS fixed, even though 1073 != 1157. Re-zeroing -#: it again must be a NO-OP. +#: re-zero. Its seam sits at raw 1073, strictly inside the arc — so the seam IS +#: evicted and the arm IS fixed, even though 1073 is not the midpoint. Re-zeroing +#: it again must be a NO-OP. A historical fact about one servo: it stays literal. OUR_ARM_OFFSET = 1073 -#: ``elbow_flex``'s measured travel: 4096 − (2107 − 207). -EXPECTED_TRAVEL = 2196 - #: STS3215 Goal_Position. Must NEVER appear in this verb's write surface. ADDR_GOAL_POSITION = 42 +#: Raw ticks a (simulated) human hand advances the joint between two polls. +SWEEP_STEP = 25 + +#: Polls needed to hand-walk the WHOLE travel at :data:`SWEEP_STEP` ticks a poll. +#: Sized from the arc, so a re-measured (wider or narrower) travel re-sizes the +#: sweep instead of quietly leaving it short of the coverage a verdict needs. +FULL_SWEEP_SAMPLES = EXPECTED_TRAVEL // SWEEP_STEP + 1 + +#: The RAW tick such a sweep finishes on, having started at the arc's high wall +#: and wound up over the 4095->0 roll-over. +FULL_SWEEP_END_RAW = (ARC_HIGH + SWEEP_STEP * (FULL_SWEEP_SAMPLES - 1)) % ENCODER_RESOLUTION + + +def reported_at(raw: int, offset: int = EXPECTED_OFFSET) -> int: + """What a servo holding *offset* REPORTS when its shaft sits at RAW *raw*. + + ``Present = (Actual - Ofs) mod 4096`` — the servo's own correction, and the + inverse of :func:`rezero.raw_from_reported`. Every "and then the servo will + say X" number in this module goes through here instead of being written down, + because X moves whenever the arc moves (the offset is the arc's midpoint), and + a written-down X is a test that breaks on a re-measurement rather than + following it. + """ + return (raw - offset) % ENCODER_RESOLUTION + + +def _full_sweep(offset: int = EXPECTED_OFFSET, step: int = SWEEP_STEP) -> "list[int]": + """The REPORTED positions a hand-walk of the joint's WHOLE travel produces. + + Walks the RAW travel from the near wall (the arc's high endpoint) up over the + 4095->0 roll-over to the far wall (its low endpoint), reporting each raw tick + through the correction a servo holding *offset* would apply. That is the + physical sweep the procedure asks an operator for, in list form — and it is + sized from the arc, so re-measuring the arc lengthens or shortens it rather + than invalidating every test that uses it. + """ + return [ + reported_at((ARC_HIGH + n * step) % ENCODER_RESOLUTION, offset) + for n in range(FULL_SWEEP_SAMPLES) + ] + # --------------------------------------------------------------------------- # Test doubles @@ -101,19 +193,20 @@ class HandMovedBus(FakeBus): Parameters ---------- start_raw: - RAW tick the shaft begins at. Defaults to ``elbow_flex``'s measured near - wall (raw 2107), the end a human is told to start from. + RAW tick the shaft begins at. Defaults to the arc's HIGH endpoint — the + near end of the joint's travel, and the wall a human is told to start + from. Derived, so a re-measured arc starts the hand in the right place. ticks_per_read: How far the hand advances the joint between two polls. Positive winds - toward the raw seam (2107 -> 4095 -> 0 -> 207), which is the direction - that crosses it. + toward the raw seam (``arc.high -> 4095 -> 0 -> arc.low``), which is the + direction that crosses it. """ def __init__( self, *args, start_raw: int = ARC_HIGH, - ticks_per_read: int = 25, + ticks_per_read: int = SWEEP_STEP, motor: int = ELBOW_MOTOR, **kwargs, ) -> None: @@ -143,35 +236,84 @@ def _rezeroed_bus(**kwargs) -> HandMovedBus: def test_the_arc_is_the_one_MEASURED_on_hardware_in_RAW_ticks(): - """The bug, pinned. The arc is (207, 2107) — raw ticks, from a real sweep. + """The bug, pinned. The arc is RAW ticks, from a real sweep — walls, inset. The table shipped with ``(126, 2020)``, which were REPORTED ticks read off a servo already holding the factory offset of 85, used as if they were raw. The 2026-07-12 hand sweep (torque off, seam already evicted, so the encoder could finally be walked across its own wrap) measured the travel as reported - 1034..3230 at ``Ofs = 1073`` — which is raw [2107, 4095] ∪ [0, 207], and so - the arc it cannot reach is (207, 2107). + 1034..3230 at ``Ofs = 1073`` — i.e. raw 2107 to raw 207, over the wrap. So the + arc it cannot reach is the complement of that, and it is in the RAW frame. """ arc = arm_spec.rezero_arc(ELBOW) - assert (arc.low, arc.high) == (ARC_LOW, ARC_HIGH) - # And the raw travel really is what the corrected sweep saw, converted back. - assert rezero.raw_from_reported(1034, OUR_ARM_OFFSET) == ARC_HIGH # near wall - assert rezero.raw_from_reported(3230, OUR_ARM_OFFSET) == ARC_LOW # far wall, at last + # The arc IS the observed walls, inset by the margin — not a pair of typed ticks. + assert (arc.low, arc.high) == (LOW_WALL + ARC_MARGIN, HIGH_WALL - ARC_MARGIN) + # And the raw travel really is what that sweep saw, converted back out of the + # frame it ran in. 1034/3230 (reported, at Ofs = 1073) and the raw 2107/207 they + # convert to are HISTORICAL FACTS about one run on one arm — so they stay literal. + assert rezero.raw_from_reported(1034, OUR_ARM_OFFSET) == 2107 # the near wall + assert rezero.raw_from_reported(3230, OUR_ARM_OFFSET) == 207 # the far wall, at last + # ...and the arc does not CLAIM either of them. It may never claim a tick the + # joint has actually been seen at, however the walls are next re-measured. + assert not arc.contains(2107) + assert not arc.contains(207) + + +def test_the_arc_is_INSET_from_the_walls_so_a_HARDER_PUSH_cannot_false_refuse_the_joint(): + """The lesson of the first cut, pinned so it cannot be re-learned the hard way. + + An arc declared AT the extremes of one hand sweep false-refused within minutes: + the joint came to rest at raw 218, eleven ticks past an edge taken from a sweep + the operator had stopped short of, and ``plan_rezero`` correctly reported that + the joint "cannot be where it says it is". A wall moved 206..218 depending on + how hard a human pushed — and at least one of these walls may be the TABLE, not + the joint's own stop, which would make the true travel WIDER still. + + So the declared arc must be a STRICT SUBSET of the unreachable region: inset on + both sides, never claiming a tick the joint has actually been seen at. Shrinking + is safe in both directions that matter — it cannot false-refuse a legal position, + and it cannot claim a tick the joint can really reach. The cost is nothing: the + arc has only to CONTAIN the seam, and one tick would do. + """ + arc = arm_spec.rezero_arc(ELBOW) + + assert ARC_MARGIN > 0 # there IS an inset, on both sides + assert arc.low == LOW_WALL + ARC_MARGIN + assert arc.high == HIGH_WALL - ARC_MARGIN + + # No tick the joint was ever SEEN at may be inside the arc — that is the + # false-refusal, and it is what the margin exists to make impossible. + assert not arc.contains(LOW_WALL) + assert not arc.contains(HIGH_WALL) + # ...nor may any tick between a wall and the arc: a harder push lands here. + assert not arc.contains(arc.low) # the endpoints are reachable by definition + assert not arc.contains(arc.high) + assert not any(arc.contains(t) for t in range(LOW_WALL, arc.low + 1)) + assert not any(arc.contains(t) for t in range(arc.high, HIGH_WALL + 1)) + + # And it is still an arc: shrinking it did not cost it the seam. + assert arc.contains(arc.midpoint) + assert arc.evicts(arm_spec.rezero_offset(ELBOW)) def test_elbow_flex_offset_is_the_midpoint_of_the_measured_arc(): - """A fresh re-zero writes 1157 — the midpoint of (207, 2107).""" + """A fresh re-zero writes the arc's midpoint — whatever the arc currently is.""" assert arm_spec.rezero_offset(ELBOW) == EXPECTED_OFFSET == (ARC_LOW + ARC_HIGH) // 2 def test_offset_is_derived_from_the_arc_not_typed(): """Correct the arc and the offset follows — the two cannot drift apart. - Not hypothetical: this is exactly what happened on 2026-07-12. The arc was - re-measured in the right frame, one tuple changed, and the target moved from - 1073 to 1157 without a line of ``rezero_offset`` being touched. Had the offset - been a typed constant, the correction would have silently left it stale — and - a stale target is a seam written into a joint's live travel. + Not hypothetical, and not a one-off: on 2026-07-12 the arc was corrected TWICE + — once into the right frame, once again to inset a margin after the tight + version false-refused — and each time one tuple changed and the target moved + with it, without a line of ``rezero_offset`` being touched. Had the offset been + a typed constant, either correction would have silently left it stale, and a + stale target is a seam written into a joint's live travel. + + This test is the production-code half of that discipline. The module header is + the test-code half: the ticks in this file are derived for exactly the same + reason, because the arc is going to move again. """ arc = arm_spec.rezero_arc(ELBOW) assert arc is not None @@ -184,31 +326,37 @@ def test_the_seam_lands_strictly_inside_the_unreachable_arc(): offset = arm_spec.rezero_offset(ELBOW) assert arc.contains(offset) assert arc.evicts(offset) - # ...with real clearance on both sides, not by one tick. - assert offset - arc.low == 950 - assert arc.high - offset == 950 + # ...dead centre, to the tick the integer division allows... + assert offset - arc.low == arc.width // 2 + assert arc.high - offset == arc.width - arc.width // 2 + # ...which is real clearance on both sides, not one tick: further from either + # wall than the whole inset the arc was already given. + assert min(offset - arc.low, arc.high - offset) > ARC_MARGIN def test_offset_fits_the_registers_sign_magnitude_range(): - """±2047 is the register's whole world; 1157 sits comfortably inside it.""" + """±2047 is the register's whole world; the target sits comfortably inside it.""" assert abs(arm_spec.rezero_offset(ELBOW)) <= arm_spec.MAX_ENCODER_OFFSET def test_arc_arithmetic_reconstructs_the_measured_travel(): - """Travel = 4096 − arc width = 2196 ticks — the number the sweep actually saw.""" + """Travel = the whole circle minus the arc — the yardstick every sweep is judged on.""" arc = arm_spec.rezero_arc(ELBOW) - assert arc.width == 1900 - assert arc.travel_ticks == EXPECTED_TRAVEL + assert arc.width == ARC_HIGH - ARC_LOW + assert arc.travel_ticks == ENCODER_RESOLUTION - arc.width == EXPECTED_TRAVEL + # The joint can reach strictly MORE than the walls that were actually found, + # because the arc was inset — never less. An over-claimed arc is a false refusal. + assert arc.travel_ticks > ENCODER_RESOLUTION - (HIGH_WALL - LOW_WALL) def test_arc_endpoints_are_reachable_and_the_interior_is_not(): - """``contains`` is the OPEN interval — the endpoints are the joint's own walls.""" + """``contains`` is the OPEN interval — the endpoints are ticks the joint may hold.""" arc = arm_spec.rezero_arc(ELBOW) - assert not arc.contains(ARC_LOW) # the far wall, measured 2026-07-12 - assert not arc.contains(ARC_HIGH) # the near wall + assert not arc.contains(ARC_LOW) # the far edge of the arc, inset from the wall + assert not arc.contains(ARC_HIGH) # the near edge assert arc.contains(ARC_LOW + 1) assert arc.contains(ARC_HIGH - 1) - assert not arc.contains(126) # its rest position: raw, past the wrap, reachable + assert not arc.contains(REST_RAW) # its rest position: raw, past the wrap, reachable assert not arc.contains(3000) # in the travel, on the far side of the seam @@ -230,13 +378,13 @@ def test_the_factory_offset_is_85_and_it_is_NOT_zero(): def test_a_FACTORY_servos_seam_sits_INSIDE_elbow_flexs_travel(): """Which is to say: the factory default IS issue #35, and 85 is where it lives. - ``elbow_flex``'s travel includes the raw band [0, 207]. A factory servo puts - its seam at raw 85 — right in it. Nothing is evicted; the joint wraps + ``elbow_flex``'s travel includes the raw band below the arc. A factory servo + puts its seam at raw 85 — right in it. Nothing is evicted; the joint wraps mid-travel; that is the bug. """ arc = arm_spec.rezero_arc(ELBOW) - assert arm_spec.seam_tick(FACTORY_OFFSET) == 85 - assert not arc.contains(85) + assert arm_spec.seam_tick(FACTORY_OFFSET) == FACTORY_OFFSET + assert not arc.contains(FACTORY_OFFSET) assert not arc.evicts(FACTORY_OFFSET) @@ -255,26 +403,29 @@ def test_seam_tick_reduces_a_SIGNED_offset_modulo_4096(): def test_seam_tick_and_offset_for_seam_at_are_inverses(): """The round-trip the whole re-zero rests on, pinned across the register's range.""" - for tick in (0, 1, 85, 207, 1073, 1157, 2047, 2049, 3000, 4095): + ticks = (0, 1, FACTORY_OFFSET, ARC_LOW, OUR_ARM_OFFSET, EXPECTED_OFFSET, 2047, 2049, 3000, 4095) + for tick in ticks: assert arm_spec.seam_tick(arm_spec._offset_for_seam_at(tick)) == tick def test_evicts_is_about_WHERE_THE_SEAM_IS_not_which_number_the_register_holds(): """The second half of the fix, and the one that saves our arm an EEPROM write. - ~1899 different offsets evict ``elbow_flex``'s seam. The midpoint is merely - the roomiest. An arm holding ANY of them is fixed — and ours holds 1073. + Every tick strictly inside the arc — well over a thousand of them — evicts + ``elbow_flex``'s seam. The midpoint is merely the roomiest. An arm holding ANY + of them is fixed, and ours holds 1073. """ arc = arm_spec.rezero_arc(ELBOW) assert arc.evicts(OUR_ARM_OFFSET) # our follower: 1073, from the first re-zero - assert arc.evicts(EXPECTED_OFFSET) # the canonical midpoint: 1157 + assert arc.evicts(EXPECTED_OFFSET) # the canonical midpoint + assert arc.evicts(ODD_EVICTING_OFFSET) # a number nobody computed — still evicted assert arc.evicts(ARC_LOW + 1) # one tick inside: ugly, but evicted assert arc.evicts(ARC_HIGH - 1) - assert not arc.evicts(0) # seam at raw 0 — in the [0, 207] band + assert not arc.evicts(0) # seam at raw 0 — below the arc, in the travel assert not arc.evicts(FACTORY_OFFSET) # seam at raw 85 — likewise - assert not arc.evicts(ARC_LOW) # the wall itself is REACHABLE + assert not arc.evicts(ARC_LOW) # the arc's own edges are REACHABLE assert not arc.evicts(ARC_HIGH) assert not arc.evicts(-1096) # seam at raw 3000 — deep in the far travel @@ -408,7 +559,7 @@ def test_require_rezeroable_rejects_an_unknown_joint_as_a_user_error(): def test_plan_reads_the_live_state_and_writes_nothing(): - bus = FakeBus(positions={ELBOW_MOTOR: 126}) # raw 126; a servo somebody zeroed + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) # raw 126; a servo somebody zeroed bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) @@ -416,8 +567,8 @@ def test_plan_reads_the_live_state_and_writes_nothing(): assert plan.current_offset == 0 assert plan.current_seam_tick == 0 # seam at raw 0 — inside the travel assert plan.target_offset == EXPECTED_OFFSET - assert plan.reported_position == 126 - assert plan.raw_position == 126 # at offset 0, and ONLY at 0, reported IS raw + assert plan.reported_position == REST_RAW + assert plan.raw_position == REST_RAW # at offset 0, and ONLY at 0, reported IS raw assert plan.already_applied is False # Planning is a read. Not one register was touched. assert bus.register_writes == [] @@ -438,75 +589,92 @@ def test_a_FACTORY_FRESH_servo_at_offset_85_IS_PLANNED_not_refused(): 126, so a servo holding 85 reports 41 — and the plan must find raw 126 behind that, not 41. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) assert plan.current_offset == FACTORY_OFFSET - assert plan.current_seam_tick == 85 # the factory seam, INSIDE the [0, 207] band - assert plan.reported_position == 41 # 126 − 85: a REPORTED tick - assert plan.raw_position == 126 # ...and the raw tick behind it + assert plan.current_seam_tick == FACTORY_OFFSET # the factory seam, INSIDE the travel + assert plan.reported_position == REST_RAW - FACTORY_OFFSET == 41 # a REPORTED tick + assert plan.raw_position == REST_RAW # ...and the raw tick behind it assert plan.already_applied is False # 85 does not evict: there is work to do assert plan.target_offset == EXPECTED_OFFSET assert bus.register_writes == [] def test_plan_predicts_what_the_servo_will_report_after_the_write(): - """From rest at raw 126, an offset of 1157 must make the servo report 3065. + """From rest, the target offset must make the servo report ``(rest − target) mod 4096``. - ``(126 − 1157) mod 4096 = 3065``. If this number is wrong, everything - downstream of it — the shift probe, the operator's sanity check — is - decoration. + If this number is wrong, everything downstream of it — the shift probe, the + operator's sanity check — is decoration. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) - assert plan.predicted_position == 3065 + assert plan.predicted_position == reported_at(REST_RAW) -@pytest.mark.parametrize( - ("raw", "predicted"), - [(2107, 950), (4000, 2843), (4095, 2938), (0, 2939), (126, 3065), (207, 3146)], -) -def test_the_whole_travel_becomes_one_contiguous_increasing_interval(raw, predicted): - """The deliverable of issue #35, row by row: 950 -> 2938 -> 2939 -> 3146. - - Walk the raw travel (2107 -> 4095 -> |raw seam| -> 0 -> 207) and the reported - values come out strictly increasing with no discontinuity: the reachable set - collapses to the single interval [950, 3146], which a ``(min, max)`` pair can - honestly describe. Note the crossing at raw 4095 -> 0: reported 2938 -> 2939, - one tick, no seam. That is the entire point. +@pytest.mark.parametrize("raw", [ARC_HIGH, 4000, 4095, 0, REST_RAW, ARC_LOW]) +def test_the_whole_travel_becomes_one_contiguous_increasing_interval(raw): + """The deliverable of issue #35, row by row. + + Walk the raw travel (``arc.high -> 4095 -> |raw seam| -> 0 -> arc.low``) and + the reported values come out with no discontinuity: the reachable set collapses + to ONE interval — from ``reported(arc.high)`` to ``reported(arc.low)`` — which a + ``(min, max)`` pair can honestly describe. Every tick of the travel lands inside + it, which is what "contiguous" means and what a wrapping encoder denies. """ bus = FakeBus(positions={ELBOW_MOTOR: raw}) bus.open() - assert rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW).predicted_position == predicted + + predicted = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW).predicted_position + + assert predicted == reported_at(raw) + assert reported_at(ARC_HIGH) <= predicted <= reported_at(ARC_LOW) + + +def test_the_raw_4095_to_0_ROLL_OVER_becomes_a_ONE_TICK_step(): + """The seam, gone — stated as the single fact the whole re-zero is for. + + The two raw ticks either side of the encoder's roll-over are adjacent angles of + one joint, and after the re-zero the servo reports them as adjacent numbers. + Before it, they reported ~4095 apart. That step, and nothing else, IS issue #35. + """ + + def predicted_at(raw: int) -> int: + bus = FakeBus(positions={ELBOW_MOTOR: raw}) + bus.open() + return rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW).predicted_position + + assert predicted_at(0) - predicted_at(4095) == 1 def test_OUR_ARM_holding_1073_is_a_NO_OP_not_a_rewrite(): """The arm on the bench. It holds 1073 and it is DONE — do not touch it. - 1073 came out of the first, frame-confused re-zero. It is not the midpoint - (that is 1157) and it does not have to be: its seam sits at raw 1073, strictly - inside the unreachable ``(207, 2107)``, and a torque-off hand sweep proved the - travel continuous across all 2196 ticks. The seam is out of the joint's - travel. That IS the goal, and it is met. + 1073 came out of the first, frame-confused re-zero. It is not the arc's + midpoint and it does not have to be: its seam sits at raw 1073, strictly + inside the unreachable arc, and a torque-off hand sweep proved the travel + continuous across all 2196 ticks. The seam is out of the joint's travel. + That IS the goal, and it is met. A verb that insisted on its own midpoint would burn an EEPROM write on a finite-write part to slide a seam from one unreachable tick to another unreachable tick, and the joint could not tell the difference. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) assert plan.already_applied is True # <- the whole test assert plan.current_offset == OUR_ARM_OFFSET - assert plan.current_seam_tick == 1073 - assert plan.target_offset == OUR_ARM_OFFSET # NOT 1157: nothing is being written - assert plan.reported_position == 3149 # (126 − 1073) mod 4096 - assert plan.raw_position == 126 # ...and we can still find the shaft + assert plan.current_seam_tick == OUR_ARM_OFFSET + assert plan.target_offset == OUR_ARM_OFFSET # NOT the midpoint: nothing is written + assert plan.target_offset != EXPECTED_OFFSET # ...and they really are different + assert plan.reported_position == reported_at(REST_RAW, OUR_ARM_OFFSET) + assert plan.raw_position == REST_RAW # ...and we can still find the shaft assert plan.predicted_position == plan.reported_position # nothing will change assert bus.register_writes == [] assert bus.offset_writes == [] @@ -519,33 +687,34 @@ def test_plan_is_idempotent_on_a_joint_holding_the_canonical_midpoint_too(): mistake, and it must be recognised rather than re-written — whether the offset in force is the midpoint or any other evicting one. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: EXPECTED_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: EXPECTED_OFFSET}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) assert plan.already_applied is True assert plan.current_offset == EXPECTED_OFFSET - assert plan.reported_position == 3065 # the servo is already reporting corrected - assert plan.raw_position == 126 # ...and we can still find the shaft + assert plan.reported_position == reported_at(REST_RAW) # already reporting corrected + assert plan.raw_position == REST_RAW # ...and we can still find the shaft assert bus.register_writes == [] def test_ANY_offset_that_already_evicts_the_seam_is_a_no_op(): - """500 is nobody's computed target — and its seam is already out of the travel. + """An offset nobody computed — and its seam is already out of the travel. The old guard refused this outright ("neither the factory 0 nor this joint's - computed 1073"). But raw 500 is strictly inside (207, 2107): the joint cannot + computed 1073"). But this seam is strictly inside the arc: the joint cannot reach it, cannot cross it, and is therefore already linear. There is nothing to do, and "I don't recognise this number" was never a reason to say otherwise. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: 500}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: ODD_EVICTING_OFFSET}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) assert plan.already_applied is True - assert plan.current_seam_tick == 500 + assert plan.current_seam_tick == ODD_EVICTING_OFFSET + assert ODD_EVICTING_OFFSET != EXPECTED_OFFSET # ...and it is nobody's target assert bus.register_writes == [] @@ -557,15 +726,15 @@ def test_an_offset_whose_seam_is_IN_the_travel_is_re_zeroed_FROM_ITS_OWN_FRAME() to find the shaft. Exercises the negative-offset path, which is where a two's-complement or a sign-blind modulo would come apart. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: -1096}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: -1096}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) assert plan.current_seam_tick == 3000 # −1096 mod 4096 — a tick, on the circle assert plan.already_applied is False - assert plan.reported_position == 1222 # (126 + 1096) mod 4096 - assert plan.raw_position == 126 # ...converted back out of ITS frame + assert plan.reported_position == reported_at(REST_RAW, -1096) == 1222 # (126 + 1096) + assert plan.raw_position == REST_RAW # ...converted back out of ITS frame assert plan.target_offset == EXPECTED_OFFSET @@ -587,7 +756,7 @@ def test_the_raw_conversion_WRAPS_modulo_4096(): assert plan.reported_position == 4000 # (104 − 200) mod 4096 — near the top assert plan.raw_position == 104 # ...and back down, over the wrap - assert plan.already_applied is False # seam at raw 200 is in the [0, 207] band + assert plan.already_applied is False # seam at raw 200 is BELOW the arc: in the travel assert plan.target_offset == EXPECTED_OFFSET @@ -603,8 +772,12 @@ def test_plan_refuses_a_joint_that_reports_a_position_it_cannot_physically_hold( frame bug had the numbers been less lucky: it is the only check that compares a live reading against the arc, so it is the only one that can notice they are in different frames. + + It is also the guard that FALSE-REFUSED when the arc was declared at the raw + extremes of a single sweep — which is why the arc now carries a margin, and why + the impossible position below is derived from the arc rather than typed. """ - bus = FakeBus(positions={ELBOW_MOTOR: 1500}) # dead centre of (207, 2107) + bus = FakeBus(positions={ELBOW_MOTOR: IMPOSSIBLE_RAW}) # dead centre of the arc bus.open() with pytest.raises(CliError) as exc: @@ -618,30 +791,34 @@ def test_plan_refuses_a_joint_that_reports_a_position_it_cannot_physically_hold( def test_the_impossible_position_guard_judges_the_RAW_tick_not_the_reported_one(): - """The same guard, in a frame. Reported 1415 at Ofs=85 is raw 1500 — impossible. + """The same guard, in a frame: a servo at Ofs=85 reports 85 ticks BELOW its shaft. A servo whose shaft is genuinely in the unreachable arc must be caught whatever offset it happens to be holding, because the arc is a fact about the shaft. Judging the reported tick instead would let a servo 85 ticks deep into an arc it cannot reach look perfectly healthy. """ - bus = FakeBus(positions={ELBOW_MOTOR: 1500}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: IMPOSSIBLE_RAW}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) bus.open() with pytest.raises(CliError) as exc: rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) + reported = reported_at(IMPOSSIBLE_RAW, FACTORY_OFFSET) assert exc.value.code == EXIT_ENV_ERROR - assert "raw encoder position 1500" in exc.value.message - assert "it reports 1415 while holding an offset of 85" in exc.value.message + assert f"raw encoder position {IMPOSSIBLE_RAW}" in exc.value.message + assert f"it reports {reported} while holding an offset of {FACTORY_OFFSET}" in exc.value.message + # The two really are different numbers — the guard is judging the right one. + assert reported != IMPOSSIBLE_RAW def test_raw_from_reported_inverts_the_servos_own_correction(): """``Actual = (Present + Ofs) mod 4096``, on every path — not just exotic ones.""" - assert rezero.raw_from_reported(126, 0) == 126 # identity ONLY at offset 0 - assert rezero.raw_from_reported(41, FACTORY_OFFSET) == 126 # the factory frame - assert rezero.raw_from_reported(3065, EXPECTED_OFFSET) == 126 # the target frame - assert rezero.raw_from_reported(3149, OUR_ARM_OFFSET) == 126 # our arm's frame + assert rezero.raw_from_reported(REST_RAW, 0) == REST_RAW # identity ONLY at offset 0 + assert rezero.raw_from_reported(41, FACTORY_OFFSET) == REST_RAW # the factory frame + assert rezero.raw_from_reported(3149, OUR_ARM_OFFSET) == REST_RAW # our arm's frame + # ...and the target frame, whose reported tick moves whenever the arc does. + assert rezero.raw_from_reported(reported_at(REST_RAW), EXPECTED_OFFSET) == REST_RAW # --------------------------------------------------------------------------- @@ -650,7 +827,7 @@ def test_raw_from_reported_inverts_the_servos_own_correction(): def test_apply_writes_the_offset_and_reads_it_back(): - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) bus.open() read_back = rezero.apply_rezero(bus, ELBOW_MOTOR, EXPECTED_OFFSET) @@ -668,7 +845,7 @@ def test_apply_COMMANDS_NO_MOTION(): test pins that at the register level rather than trusting the code to continue not doing it. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) bus.open() rezero.apply_rezero(bus, ELBOW_MOTOR, EXPECTED_OFFSET) @@ -679,7 +856,7 @@ def test_apply_COMMANDS_NO_MOTION(): def test_apply_never_ENERGISES_the_joint(): """Torque only ever goes OFF. A servo must not be holding while its frame moves.""" - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) bus.open() rezero.apply_rezero(bus, ELBOW_MOTOR, EXPECTED_OFFSET) @@ -692,7 +869,7 @@ def test_apply_never_ENERGISES_the_joint(): def test_apply_performs_the_full_EEPROM_lock_dance(): """Unlock -> write addr 31 -> re-lock. Skip it and the write REVERTS (PR #21).""" - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) bus.open() rezero.apply_rezero(bus, ELBOW_MOTOR, EXPECTED_OFFSET) @@ -716,7 +893,7 @@ def test_apply_writes_addr_31_and_NOTHING_else_in_eeprom(): shrinking the very reachable set this re-zero exists to recover. The write surface is pinned to an allow-list so widening it is a deliberate act. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) bus.open() rezero.apply_rezero(bus, ELBOW_MOTOR, EXPECTED_OFFSET) @@ -734,7 +911,7 @@ def test_apply_clears_a_latched_overload_before_the_write(): EEPROM. That is not hypothetical: driving into a wall is how ``elbow_flex``'s arc was measured. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}).fail_with_overload_on_op(1) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}).fail_with_overload_on_op(1) bus.open() read_back = rezero.apply_rezero(bus, ELBOW_MOTOR, EXPECTED_OFFSET) @@ -744,7 +921,7 @@ def test_apply_clears_a_latched_overload_before_the_write(): def test_a_latched_motor_defeats_a_write_that_SKIPS_clear_overload(): """The negative control for the test above — proving the guard is load-bearing.""" - bus = FakeBus(positions={ELBOW_MOTOR: 126}).fail_with_overload_on_op(1) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}).fail_with_overload_on_op(1) bus.open() with pytest.raises(OverloadError): @@ -753,7 +930,7 @@ def test_a_latched_motor_defeats_a_write_that_SKIPS_clear_overload(): def test_an_unrepresentable_offset_is_rejected_before_ANY_wire_traffic(): """A rejected offset must not leave the joint limp as a side effect of failing.""" - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) bus.open() with pytest.raises(CliError) as exc: @@ -770,14 +947,14 @@ def test_an_unrepresentable_offset_is_rejected_before_ANY_wire_traffic(): def test_shift_matches_the_prediction_when_the_offset_wraps(): - bus = FakeBus(positions={ELBOW_MOTOR: 126}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) rezero.apply_rezero(bus, ELBOW_MOTOR, plan.target_offset) shift = rezero.describe_shift(plan, bus.read_position(ELBOW_MOTOR)) - assert shift["observed_position"] == 3065 + assert shift["observed_position"] == reported_at(REST_RAW) assert shift["as_predicted"] is True assert shift["in_range"] is True assert shift["unchanged"] is False @@ -787,40 +964,40 @@ def test_shift_matches_the_prediction_from_the_FACTORY_frame_too(): """The probe has to survive the frame conversion, or it warns on every real arm. A factory servo at ``Ofs = 85`` reports 41 from rest. After the write it must - report ``(126 − 1157) mod 4096 = 3065`` — the same place, because the SHAFT did - not move; only the frame did. A probe that predicted from the reported 41 - instead of the raw 126 would be off by exactly the factory offset and would cry - wolf on every single fresh arm. + report ``(126 − target) mod 4096`` — the same PLACE as a servo that started from + any other offset, because the SHAFT did not move; only the frame did. A probe + that predicted from the reported 41 instead of the raw 126 would be off by + exactly the factory offset and would cry wolf on every single fresh arm. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offsets={ELBOW_MOTOR: FACTORY_OFFSET}) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) rezero.apply_rezero(bus, ELBOW_MOTOR, plan.target_offset) shift = rezero.describe_shift(plan, bus.read_position(ELBOW_MOTOR)) - assert shift["predicted_position"] == 3065 - assert shift["observed_position"] == 3065 + assert shift["predicted_position"] == reported_at(REST_RAW) + assert shift["observed_position"] == reported_at(REST_RAW) assert shift["as_predicted"] is True assert shift["unchanged"] is False def test_shift_catches_the_signed_reading_IMMEDIATELY(): - """Under ``offset_wraps=False`` the servo reports −1031 from rest — impossible. + """Under ``offset_wraps=False`` the servo reports a NEGATIVE tick from rest. - A position register cannot hold a negative number, so this alone already - proves the corrected position is an unwrapped signed subtraction and the - seam never moved. The sweep is still the proof; this is the free warning - that arrives 30 seconds earlier. + ``rest − target`` is below zero, and a position register cannot hold a negative + number — so this alone already proves the corrected position is an unwrapped + signed subtraction and the seam never moved. The sweep is still the proof; this + is the free warning that arrives 30 seconds earlier. """ - bus = FakeBus(positions={ELBOW_MOTOR: 126}, offset_wraps=False) + bus = FakeBus(positions={ELBOW_MOTOR: REST_RAW}, offset_wraps=False) bus.open() plan = rezero.plan_rezero(bus, ELBOW_MOTOR, ELBOW) rezero.apply_rezero(bus, ELBOW_MOTOR, plan.target_offset) shift = rezero.describe_shift(plan, bus.read_position(ELBOW_MOTOR)) - assert shift["observed_position"] == 126 - EXPECTED_OFFSET == -1031 + assert shift["observed_position"] == REST_RAW - EXPECTED_OFFSET < 0 # impossible assert shift["in_range"] is False assert shift["as_predicted"] is False @@ -841,8 +1018,8 @@ def _analyse(positions, offset_in_force=EXPECTED_OFFSET): def test_a_clean_full_sweep_is_a_PASS(): - """950 -> 3146, monotonic, no jump: the seam is gone. Issue #35 is fixed.""" - report = _analyse(list(range(950, 3147, 25))) + """Wall to wall, monotonic, no jump: the seam is gone. Issue #35 is fixed.""" + report = _analyse(_full_sweep()) assert report.continuous is True assert report.conclusive is True @@ -850,29 +1027,33 @@ def test_a_clean_full_sweep_is_a_PASS(): assert report.seam_evicted is True assert report.verdict == rezero.VERDICT_SEAM_EVICTED assert report.failed is False - assert report.largest_jump == 25 - assert (report.minimum, report.maximum) == (950, 3125) + assert report.largest_jump == SWEEP_STEP # the hand's own step, and nothing bigger + # The extremes are the two walls, seen through the re-zeroed frame — and the + # near wall reports LOWER than the far one, which is the seam being gone. + assert report.minimum == reported_at(ARC_HIGH) + assert report.maximum == reported_at(FULL_SWEEP_END_RAW) assert report.expected_travel == EXPECTED_TRAVEL # derived from the arc, not passed def test_a_sweep_of_OUR_ARM_at_1073_is_a_PASS_not_an_INCONCLUSIVE(): """The report must not deny the measurement in front of it. - Our follower holds 1073, which is not the arc's midpoint (1157) and does not - need to be: its seam sits at raw 1073, strictly inside (207, 2107). Judging - ``rezeroed`` by ``offset == expected_offset`` would call this arm un-re-zeroed - and downgrade the very sweep that PROVED the fix works to "inconclusive" — - on the grounds that a register held the wrong integer. + Our follower holds 1073, which is not the arc's midpoint and does not need to + be: its seam sits at raw 1073, strictly inside the arc. Judging ``rezeroed`` by + ``offset == expected_offset`` would call this arm un-re-zeroed and downgrade the + very sweep that PROVED the fix works to "inconclusive" — on the grounds that a + register held the wrong integer. """ - report = _analyse(list(range(1034, 3231, 25)), offset_in_force=OUR_ARM_OFFSET) + report = _analyse(_full_sweep(OUR_ARM_OFFSET), offset_in_force=OUR_ARM_OFFSET) - assert report.seam_tick == 1073 + assert report.seam_tick == OUR_ARM_OFFSET assert report.rezeroed is True # <- the whole test assert report.continuous is True assert report.conclusive is True assert report.verdict == rezero.VERDICT_SEAM_EVICTED # It still reports the canonical target, it just does not JUDGE by it. assert report.expected_offset == EXPECTED_OFFSET + assert report.offset_in_force != report.expected_offset # ...and they differ def test_a_4095_to_0_wrap_under_a_written_offset_is_the_STOP_condition(): @@ -900,16 +1081,21 @@ def test_the_MASKED_signed_jump_is_caught_too_and_it_is_why_the_threshold_is_not If the firmware does a plain signed subtraction AND ``read_position``'s ``& 0x0FFF`` folds the negative result back into range, the report falls from - ``4095 − H`` to ``H`` at the seam: a jump of ``4095 − 2H``, only **1781 ticks** - at ``H = 1157`` — comfortably UNDER the tempting 2048 threshold. A 2048 + ``4095 − H`` to ``H`` at the seam: a jump of ``4095 − 2H``, which for the target + this arc yields is comfortably UNDER the tempting 2048 threshold. A 2048 threshold would call this a pass and tell the operator the fix works. + + Note the jump SHRINKS as the seam approaches raw 2048 — so it is a function of + the arc, and pinning it as a literal would be pinning the wrong thing. """ - jump = (4095 - EXPECTED_OFFSET) - EXPECTED_OFFSET - assert jump == 1781 < 2048 # the trap, stated - report = _analyse([2900, 2930, 2938, 1157, 1200, 1250]) + top = 4095 - EXPECTED_OFFSET # what the servo reports just BEFORE the seam... + jump = top - EXPECTED_OFFSET # ...and how far it then falls + assert jump < 2048 # the trap, stated: a 2048 threshold would sail past this - assert report.largest_jump == 1781 - assert rezero.DISCONTINUITY_TICKS <= 1781 + report = _analyse([top - 38, top - 8, top, EXPECTED_OFFSET, EXPECTED_OFFSET + 43]) + + assert report.largest_jump == jump + assert rezero.DISCONTINUITY_TICKS <= jump # ...and OUR threshold catches it assert report.continuous is False assert report.failed is True @@ -945,12 +1131,13 @@ def test_an_unre_zeroed_discontinuous_sweep_is_a_BASELINE_not_a_failure(): def test_a_short_clean_sweep_is_INCONCLUSIVE_never_a_pass(): """The most dangerous outcome available, and the one it must never claim. - A sweep that moved the joint 190 of its 2196 ticks and saw no seam has proved + A sweep that moved the joint 190 ticks of its ~2400 and saw no seam has proved NOTHING — of course it saw no seam; it never went near where the seam would be. Reporting that as a pass would close the open question with a lie. """ report = _analyse(list(range(1000, 1200, 10))) + assert report.span < EXPECTED_TRAVEL * rezero.MIN_COVERAGE # nowhere near enough assert report.continuous is True assert report.rezeroed is True assert report.conclusive is False @@ -968,16 +1155,16 @@ def test_a_clean_sweep_of_a_joint_whose_seam_is_STILL_IN_ITS_TRAVEL_is_INCONCLUS held: a factory servo at ``Ofs = 85`` has its seam at raw 85, inside the joint's travel, and is exactly as un-re-zeroed as one at 0. """ - report = _analyse(list(range(950, 3147, 25)), offset_in_force=FACTORY_OFFSET) + report = _analyse(_full_sweep(), offset_in_force=FACTORY_OFFSET) - assert report.seam_tick == 85 # inside the reachable [0, 207] band + assert report.seam_tick == FACTORY_OFFSET # below the arc: in the reachable band assert report.rezeroed is False assert report.continuous is True assert report.conclusive is True # it covered the travel... assert report.seam_evicted is False # ...but there was no eviction to prove assert report.verdict == rezero.VERDICT_INCONCLUSIVE assert "was NOT re-zeroed" in report.describe() - assert "seam at raw tick 85" in report.describe() + assert f"seam at raw tick {FACTORY_OFFSET}" in report.describe() def test_a_DISCONTINUOUS_sweep_is_conclusive_however_short_it_was(): @@ -995,13 +1182,14 @@ def test_a_hand_that_backs_up_is_reported_but_never_FAILS_the_sweep(): cannot fake in either direction — and a verb that failed an honest sweep because the operator's grip slipped would teach them to distrust it. """ - positions = ( - list(range(947, 2000, 25)) + list(range(2000, 1800, -25)) + list(range(1800, 3150, 25)) - ) - report = _analyse(positions) + forward = _full_sweep() + slip = len(forward) // 2 # halfway along, the grip goes... + backed_up = forward[slip - 8 : slip][::-1] # ...eight samples the wrong way... + report = _analyse(forward[:slip] + backed_up + forward[slip - 8 :]) # ...then on assert report.monotonic is False # it went both ways... assert report.continuous is True # ...but never jumped + assert report.conclusive is True # ...and it still covered the travel assert report.seam_evicted is True # ...so the proof still stands assert report.verdict == rezero.VERDICT_SEAM_EVICTED @@ -1032,14 +1220,15 @@ def test_a_passing_report_states_the_travel_it_measured_IN_BOTH_FRAMES(): the next person exactly the numbers that caused this bug: ticks in one frame, destined for a table in the other. So it converts, and it says which is which. """ - report = _analyse(list(range(950, 3147, 25))) + report = _analyse(_full_sweep()) text = report.describe() assert "Travel measured" in text assert f"{report.span} ticks" in text assert "RAW" in text - # 950 and 3125 reported at Ofs=1157 are raw 2107 and 186 — over the wrap. - assert "RAW 2107 .. 186" in text + # The sweep ran from the arc's high wall, over the wrap, to its low one — and + # the report hands those RAW walls back, not the reported ticks it saw them as. + assert f"RAW {ARC_HIGH} .. {FULL_SWEEP_END_RAW}" in text assert "mod 4096" in text # and it tells you how to convert @@ -1073,16 +1262,16 @@ def test_sweep_PROVES_the_seam_moved_when_the_offset_wraps(): """ bus = _rezeroed_bus(offset_wraps=True) - report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=90) + report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=FULL_SWEEP_SAMPLES) assert report.rezeroed is True assert report.continuous is True assert report.conclusive is True assert report.seam_evicted is True assert report.verdict == rezero.VERDICT_SEAM_EVICTED - assert report.largest_jump == 25 # the hand's own step, and nothing bigger - assert report.minimum == 950 # raw 2107, the near wall, in the corrected frame - assert max(report.samples) >= 3100 # ...all the way past rest + assert report.largest_jump == SWEEP_STEP # the hand's own step, and nothing bigger + assert report.minimum == reported_at(ARC_HIGH) # the near wall, in the corrected frame + assert max(report.samples) >= reported_at(REST_RAW) # ...all the way past rest def test_sweep_FAILS_LOUDLY_when_the_offset_does_not_wrap(): @@ -1097,7 +1286,7 @@ def test_sweep_FAILS_LOUDLY_when_the_offset_does_not_wrap(): """ bus = _rezeroed_bus(offset_wraps=False) - report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=90) + report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=FULL_SWEEP_SAMPLES) assert report.rezeroed is True assert report.continuous is False # <- the seam is STILL in the travel @@ -1113,7 +1302,7 @@ def test_sweep_of_a_FACTORY_joint_shows_the_bug_itself(): bus = HandMovedBus() # offset 0 — a factory servo bus.open() - report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=90) + report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=FULL_SWEEP_SAMPLES) assert report.rezeroed is False assert report.continuous is False @@ -1158,8 +1347,8 @@ def test_sweep_reports_the_offset_ACTUALLY_in_force_not_the_one_we_hoped_for(): def test_sweep_of_OUR_ARM_at_1073_PROVES_the_seam_moved(): """End to end, on the offset the real follower is actually carrying. - 1073 is not the midpoint and never will be. Its seam is at raw 1073, inside - (207, 2107), so the joint cannot cross it — and the sweep says so, in full, as + 1073 is not the midpoint and never will be. Its seam is at raw 1073, inside the + arc, so the joint cannot cross it — and the sweep says so, in full, as ``seam-evicted``. This is the run that was done on hardware on 2026-07-12 (``monotonic: True, discontinuities: 0`` over 2196 ticks); if the code cannot return a PASS for it, the code is disagreeing with the arm. @@ -1167,15 +1356,16 @@ def test_sweep_of_OUR_ARM_at_1073_PROVES_the_seam_moved(): bus = HandMovedBus(offsets={ELBOW_MOTOR: OUR_ARM_OFFSET}) bus.open() - report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=90) + report = rezero.sweep(bus, ELBOW_MOTOR, ELBOW, samples=FULL_SWEEP_SAMPLES) assert report.offset_in_force == OUR_ARM_OFFSET - assert report.seam_tick == 1073 + assert report.seam_tick == OUR_ARM_OFFSET assert report.rezeroed is True assert report.continuous is True assert report.seam_evicted is True assert report.verdict == rezero.VERDICT_SEAM_EVICTED - assert report.minimum == 1034 # raw 2107 − 1073 — the reported near wall, as measured + # The near wall, as OUR arm's frame reports it — not as the midpoint's would. + assert report.minimum == reported_at(ARC_HIGH, OUR_ARM_OFFSET) def test_sweep_invokes_the_on_sample_hook_for_every_poll(): From c56d8e8f089a521571a23d9eda7daae606f92ce8 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sun, 12 Jul 2026 16:09:39 +0300 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20the=20re-zero=20is=20proven=20on=20h?= =?UTF-8?q?ardware=20=E2=80=94=20and=20the=20arc=20was=20in=20the=20wrong?= =?UTF-8?q?=20frame=20(v0.22.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #35. A live session on the follower with the operator present. THE RESULT. The re-zero WORKS. The STS3215 reduces the corrected position MODULO 4096, so a homing offset genuinely RELOCATES the encoder seam rather than merely relabelling positions. That was the one unproven assumption the whole of issue #35 rested on — the t5 spike said GO-WITH-CAVEAT precisely because no document states it — and it is now settled by measurement. Same joint, same hand sweep, before and after: offset 0 -> monotonic FALSE, 1 discontinuity, span 4093 offset 1073 -> monotonic TRUE, 0 discontinuities, span 2196 It survived a power cycle, read back cold. PR #21's ghost is laid. THE MECHANISM, not just the symptom. Every SO-101 ships with Ofs = +85, NOT the factory 0 the spike assumed (uniform across six servos => a vendor default). And that is exactly 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's travel. We did not merely fix the bug; we now know why it happened. THE FRAME BUG. REZERO_ARCS held REPORTED ticks — read off a servo already carrying that factory offset — and used them as RAW. The old target landed inside the true arc anyway with ~866 ticks of margin, so it worked, and every read-back looked correct. That is the most dangerous way for a frame bug to behave. The arc is now RAW by construction, and raw = (reported + offset) mod 4096 on every path. The `current in (0, target)` guard is gone. It refused outright on a servo holding the factory 85 — i.e. on EVERY FRESH SO-101. The goal is now stated as a PLACE (the seam is out of the travel), not a magic number: an arm already holding a seam-evicting offset is a clean NO-OP, not a rewrite. THE ARM MEASURED ITS OWN WALLS. Once the axis was linear, gentle_move could be driven past the known travel and left to find each wall by feel — creep, watch the load, stop on contact. Both contacts saturated at the 500 torque cap. It out-measured the human on BOTH sides (raw 251 vs 218; 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, so a harder push can never make the table contradict the arm — which is exactly what a tight arc did, within minutes. And the second wall-find crossed the raw 4095->0 boundary as a plain linear climb. That command would have rotated the elbow the LONG WAY ROUND into a wall yesterday. It is the whole day's work in one move. TESTS NO LONGER COPY THE TABLE. ~125 arc-coupled literals were hard-coded across two files, so re-measuring a table that EXISTS to be re-measured broke 34 tests. They now derive from arm_spec; changing a wall or the margin leaves the suite green. Same rule for prose: the docstrings and the explain catalog no longer quote ticks at all — and explain is what prints to whoever is standing at the arm. TWO BUS BUGS, both hit live on a healthy bus: - a dropped packet raised a bare IndexError from inside the vendor SDK instead of a CliError, leaking a raw traceback. Reads now convert and RETRY; 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. - a read immediately after an EEPROM write returned 0 while the servo genuinely held 3387. A plausible-looking wrong value is far more dangerous than an error. EEPROM writes now settle. 1242 tests pass (was 1202). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6 --- CHANGELOG.md | 19 +++++++++++ arm101/explain/catalog.py | 10 +++--- arm101/hardware/arm_spec.py | 47 +++++++++++++++++--------- arm101/hardware/rezero.py | 14 +++++--- docs/hardware-rezero-run-2026-07-12.md | 46 ++++++++++++++++++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 112 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 354d5f0..053c2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/arm101/explain/catalog.py b/arm101/explain/catalog.py index a9a8332..d1525c6 100644 --- a/arm101/explain/catalog.py +++ b/arm101/explain/catalog.py @@ -885,7 +885,9 @@ - **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**: `elbow_flex`'s arc is `(207, 2107)`, from +`arm_spec.REZERO_ARCS` is **RAW ticks**. The live numbers are in that table and +nowhere else — they are re-measured on hardware, so this text does not repeat +them (a doc that names a tick is a doc that goes stale). The arc comes from a torque-off hand sweep that measured its travel at 2196 ticks — raw `[2107, 4095] ∪ [0, 207]`, which *wraps*, which is exactly the fact a `[min, max]` pair cannot express. Every live reading is converted (`raw = (reported + offset) @@ -924,14 +926,14 @@ "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` (**1157** for `elbow_flex`) is simply the one +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 `(207, 2107)`, and a hand sweep proved its travel continuous. Rewriting it -to 1157 would spend an EEPROM write to slide a seam from one unreachable tick to +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. diff --git a/arm101/hardware/arm_spec.py b/arm101/hardware/arm_spec.py index 43073ef..b69c75d 100644 --- a/arm101/hardware/arm_spec.py +++ b/arm101/hardware/arm_spec.py @@ -898,16 +898,25 @@ def _offset_for_seam_at(tick: int) -> int: #: measured on is NOT re-written to 1157: it holds 1073, which #: :meth:`UnreachableArc.evicts` — the seam is out of the travel, the axis is #: linear, and the job is done. See :func:`rezero_offset`. -#: The furthest into the LOW band ``elbow_flex`` was ever observed (raw ticks), -#: across every hardware run on 2026-07-12. Deliberately the MAXIMUM seen, not the -#: last seen: one sweep stopped at 206, but the joint later came to rest at 218, -#: and an arc edge set at 206 promptly false-refused. Take the furthest. -_LOW_WALL_OBSERVED = 218 - -#: The furthest into the HIGH band ``elbow_flex`` was ever observed (raw ticks). -#: Same rule — the smallest high-band tick seen across all runs (2107; a later run -#: only reached 2118), because that is the deepest the joint actually got. -_HIGH_WALL_OBSERVED = 2107 +#: The furthest into the LOW band ``elbow_flex`` can reach (raw ticks) — measured +#: BY THE ARM ITSELF, not by hand. +#: +#: ``gentle_move`` was driven past the known travel and let the load-watch find the +#: wall: it contacted at corrected 3274 (raw 251) with ``present_load`` SATURATED at +#: the 500 torque cap, backed off, and held. A saturated load is the signature of a +#: real wall, not of an operator deciding it felt firm enough. +#: +#: THE ARM BEATS THE HAND. Successive human sweeps put this wall at 206, then 218 — +#: it moves with how hard you push. The arm presses to a fixed load threshold every +#: time, so it is both further (251) and REPEATABLE. An earlier arc edge set from a +#: hand sweep false-refused within minutes when the joint came to rest one tick past +#: it. Take the machine's number. +_LOW_WALL_OBSERVED = 251 + +#: The deepest into the HIGH band ``elbow_flex`` can reach (raw ticks) — again +#: measured by the arm: contact at corrected 988 (raw 2061), load saturated at 500. +#: Hand sweeps only ever reached 2107/2118; the arm pushed 46 ticks further. +_HIGH_WALL_OBSERVED = 2061 #: Inset applied to EACH side of the measured envelope before declaring the arc. #: @@ -926,10 +935,16 @@ def _offset_for_seam_at(tick: int) -> int: # MARGIN. Read the margin note below before "tightening" this to the measured # numbers — the tight version is what broke. # - # WIDEST REACHABLE ENVELOPE observed across every run that day (take the - # furthest point ever seen on each side, not the last one): - # raw reachable = [2107, 4095] ∪ [0, 218] - # => the true unreachable arc is AT MOST (218, 2107) + # REACHABLE ENVELOPE, measured BY THE ARM (gentle_move driven past the known + # travel until the load-watch found each wall; both contacts saturated at the + # 500 torque cap, which is what a real wall looks like): + # raw reachable = [2061, 4095] ∪ [0, 251] + # => the true unreachable arc is AT MOST (251, 2061), width 1810 + # + # The arm out-measured the human on BOTH sides (hand: 218 / 2107). It presses to + # a fixed load every time instead of to whatever felt firm, so its walls are + # further out AND repeatable. This is the arm feeling its own body — the same + # primitive `arm explore` uses, which is the point of the whole exercise. # # A first cut declared exactly (207, 2107) — the extremes of one sweep — and # it FALSE-REFUSED within minutes: the joint came to rest at raw 218, eleven @@ -958,8 +973,8 @@ def _offset_for_seam_at(tick: int) -> int: # raw 1073 been reachable. The arc only has to CONTAIN the seam; one tick would # do, and it keeps ~1700. "elbow_flex": UnreachableArc( - low=_LOW_WALL_OBSERVED + _ARC_MARGIN_TICKS, # 218 + 100 = 318 - high=_HIGH_WALL_OBSERVED - _ARC_MARGIN_TICKS, # 2107 - 100 = 2007 + low=_LOW_WALL_OBSERVED + _ARC_MARGIN_TICKS, # 251 + 100 = 351 + high=_HIGH_WALL_OBSERVED - _ARC_MARGIN_TICKS, # 2061 - 100 = 1961 ), } diff --git a/arm101/hardware/rezero.py b/arm101/hardware/rezero.py index 7617ac3..85c4684 100644 --- a/arm101/hardware/rezero.py +++ b/arm101/hardware/rezero.py @@ -13,7 +13,9 @@ The fix is to shift the encoder's zero (``Ofs``/``Homing_Offset``, EEPROM addr 31) so the seam falls inside the arc the joint physically cannot reach -(:class:`~arm101.hardware.arm_spec.UnreachableArc`, raw ``(207, 2107)``). Then +(:class:`~arm101.hardware.arm_spec.UnreachableArc`, in RAW ticks — see +:data:`~arm101.hardware.arm_spec.REZERO_ARCS`; the numbers live THERE and are +re-measured on hardware, so this prose deliberately does not repeat them). Then every tick the joint can actually reach lies on one side of the seam, and the linear-axis assumption the whole codebase already makes becomes TRUE rather than merely assumed. @@ -147,7 +149,8 @@ #: * Offset written, firmware does a plain signed subtraction, and #: :meth:`~arm101.hardware.bus.FeetechBus.read_position`'s ``& 0x0FFF`` folds #: the negative result back into range: the report falls from ``4095 - H`` to -#: ``H``, a jump of ``4095 - 2H`` — **~1781 ticks** at the current ``H = 1157``. +#: ``H``, a jump of ``4095 - 2H`` — over 1700 ticks at any plausible ``H``, +#: which is why the discontinuity threshold does not need to be near 4096. #: **This is the smallest discontinuity we can be shown**, and it is why the #: threshold is not the tempting 2048. (Note it *shrinks* as the seam is #: centred: an arc whose midpoint approached 2048 would shrink it toward zero. @@ -236,7 +239,8 @@ def require_rezeroable(joint: str) -> "tuple[int, arm_spec.UnreachableArc]": ------- tuple[int, UnreachableArc] The signed encoder offset a *fresh* re-zero would write — the arc's - midpoint, ``+1157`` for ``elbow_flex``, the only re-zeroable joint on + midpoint (see :data:`~arm101.hardware.arm_spec.REZERO_ARCS` for the live + value) for ``elbow_flex``, the only re-zeroable joint on this arm — and the RAW-tick unreachable arc it was derived from. The arc is the more important half. The offset is one of ~1899 that @@ -607,7 +611,7 @@ class SweepReport: :attr:`conclusive` is judged on — and it is what measured ``elbow_flex``'s far wall for the first time on 2026-07-12 (2196 ticks, reported 1034..3230 at ``Ofs = 1073``, which is where the raw arc - ``(207, 2107)`` in :data:`~arm101.hardware.arm_spec.REZERO_ARCS` comes + the arc in :data:`~arm101.hardware.arm_spec.REZERO_ARCS` comes from). monotonic: The reported position never both rose and fell by more than @@ -679,7 +683,7 @@ def rezeroed(self) -> bool: Judging this by ``offset_in_force == expected_offset`` would call our own follower — which holds ``1073``, whose seam sits at raw 1073, deep inside - ``(207, 2107)``, and whose hand sweep came back clean across all 2196 + the arc, and whose sweep came back clean across the whole ticks of travel — **not re-zeroed**, and downgrade the very run that proved the fix works to ``inconclusive``. The report would be denying the measurement in front of it on the grounds that a register held the wrong diff --git a/docs/hardware-rezero-run-2026-07-12.md b/docs/hardware-rezero-run-2026-07-12.md index b0dce27..95f07f2 100644 --- a/docs/hardware-rezero-run-2026-07-12.md +++ b/docs/hardware-rezero-run-2026-07-12.md @@ -99,13 +99,57 @@ was mishandled. The unlock → write → re-lock dance held. Both halves of issue #35 are therefore settled for `elbow_flex` **on hardware**: the seam is evicted (proven by a hand sweep) *and* the fix persists across a power cycle (proven cold). +## The arm measured its own walls — better than the human did + +Once the axis was linear, `gentle_move` could be driven **past** the known travel and +allowed to find each wall by feel: creep under torque, watch the load, stop on contact, +back off, hold. The blind-person-in-a-room primitive — and the same one `arm explore` +uses, which is the whole point of the exercise. + +```text +arm flex elbow_flex --to 900 --gentle -> contacted 988 load 500 (SATURATED) +arm flex elbow_flex --to 3400 --gentle -> contacted 3274 load 500 (SATURATED) +``` + +**The second of those is the proof of the entire day's work.** Commanding 3400 crosses the +raw 4095→0 boundary — and in the corrected frame it is simply a linear climb, which +converged. That exact command, before the re-zero, would have rotated the elbow *the long +way round* into a wall. + +The machine out-measured the hand on **both** sides: + +| wall (raw ticks) | by hand | by the arm | +|---|---|---| +| low band | 218 | **251** | +| high band | 2107 | **2061** | + +A human pushes until it *feels* firm — successive sweeps put the low wall at 206, then +218. The arm presses to a fixed load threshold every time, so its walls are both **further +out** and **repeatable**. Both contacts saturated `present_load` at the 500 torque cap, +which is the signature of a real wall rather than of an operator's judgement. + +True unreachable arc: **(251, 2061)**, width 1810. The previously declared `(318, 2007)` +was *still* a strict subset — the margin absorbed the difference, with 67 and 54 ticks to +spare. It held, which is exactly why it was there. + +### The lesson that outlives the numbers + +The tests used to **copy** the arc — ~125 arc-coupled literals across two files — so +re-measuring a table *that exists to be re-measured* broke 34 tests. They now derive every +expectation from `arm_spec`, and the acid test is explicit: change `_ARC_MARGIN_TICKS` or +either wall, and the suite stays green. The measured numbers live in **one** place. + +The same rule now applies to prose: the docstrings and the `explain` catalog no longer +quote ticks at all. A document that names a measurement is a document that goes stale — and +the `explain` text is what prints to whoever is standing at the arm. + ## Current state of the arm | joint | motor | offset | note | |---|---|---|---| | shoulder_pan | 1 | 85 | untouched (factory) | | shoulder_lift | 2 | 85 | untouched (factory) | -| **elbow_flex** | **3** | **1073** | **re-zeroed — seam evicted, verified by sweep** | +| **elbow_flex** | **3** | **1073** | **re-zeroed — seam evicted, verified by sweep AND by the arm's own wall-find** | | wrist_flex | 4 | 85 | untouched (factory) | | wrist_roll | 5 | 85 | untouched — handled by a soft limit, not a re-zero | | gripper | 6 | 85 | untouched (factory) | diff --git a/pyproject.toml b/pyproject.toml index fb926be..48ed5ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arm101-cli" -version = "0.21.0" +version = "0.22.0" description = "Agent and CLI for controlling SO-ARM101 robotic arm grippers" readme = "README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index 626df8c..6b498e0 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "arm101-cli" -version = "0.21.0" +version = "0.22.0" source = { editable = "." } [package.optional-dependencies] From 12609fa0ddb5027025dd0da7e200342a14d35404 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sun, 12 Jul 2026 16:12:21 +0300 Subject: [PATCH 6/7] refactor(sonar): type _sdk_read's callable; drop the arithmetic comments (S5864, S125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S5864 — _sdk_read took 'fn: object', which Sonar correctly flags as not callable (and which needed a type: ignore[operator] to call). Typed as Callable[..., tuple[int, int, int]]; the ignore is gone. S125 x2 — Sonar read '# 251 + 100 = 351' as commented-out code. It is not, but it should go anyway, and the reason is the point of this whole PR: those comments NAME TICKS. They restate constants that exist to be re-measured, and they would go stale the next time the arm finds its own walls — which is exactly the failure mode this PR removes from the tests and the docstrings. Deleting them is consistent, not a concession. 1242 tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6 --- arm101/hardware/arm_spec.py | 4 ++-- arm101/hardware/bus.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/arm101/hardware/arm_spec.py b/arm101/hardware/arm_spec.py index b69c75d..a222b2f 100644 --- a/arm101/hardware/arm_spec.py +++ b/arm101/hardware/arm_spec.py @@ -973,8 +973,8 @@ def _offset_for_seam_at(tick: int) -> int: # raw 1073 been reachable. The arc only has to CONTAIN the seam; one tick would # do, and it keeps ~1700. "elbow_flex": UnreachableArc( - low=_LOW_WALL_OBSERVED + _ARC_MARGIN_TICKS, # 251 + 100 = 351 - high=_HIGH_WALL_OBSERVED - _ARC_MARGIN_TICKS, # 2061 - 100 = 1961 + low=_LOW_WALL_OBSERVED + _ARC_MARGIN_TICKS, + high=_HIGH_WALL_OBSERVED - _ARC_MARGIN_TICKS, ), } diff --git a/arm101/hardware/bus.py b/arm101/hardware/bus.py index f69d557..c2b9929 100644 --- a/arm101/hardware/bus.py +++ b/arm101/hardware/bus.py @@ -17,7 +17,7 @@ import contextlib import time from types import TracebackType -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable from arm101.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError @@ -898,7 +898,13 @@ def _status_error( remediation=remediation, ) - def _sdk_read(self, fn: object, motor: int, addr: int, action: str) -> "tuple[int, int, int]": + def _sdk_read( + self, + fn: "Callable[..., tuple[int, int, int]]", + motor: int, + addr: int, + action: str, + ) -> "tuple[int, int, int]": """Call an SDK read function; convert any bare exception it raises into a CliError. *fn* is one of ``self._packet_handler.read1ByteTxRx`` / @@ -938,7 +944,7 @@ def _sdk_read(self, fn: object, motor: int, addr: int, action: str) -> "tuple[in from arm101.cli._errors import EXIT_ENV_ERROR, CliError try: - return fn(self._port_handler, motor, addr) # type: ignore[operator] + return fn(self._port_handler, motor, addr) except CliError: raise except Exception as exc: # noqa: BLE001 - the SDK can raise ~anything internally From b2eed82729e76c5b04a353031b440130e52ba4c6 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Sun, 12 Jul 2026 16:51:42 +0300 Subject: [PATCH 7/7] fix(explain): render the arc from arm_spec instead of quoting a snapshot of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qodo caught the sharpest possible version of this PR's own bug: the explain text CLAIMED 'this text does not repeat them (a doc that names a tick is a doc that goes stale)' — and then went on to name ticks. Raw ranges and a travel count, already stale by the time they were written, because the arm re-measured its own walls an hour later. A doc that lies about its own staleness is worse than one that is merely stale. Deleting the numbers was the easy fix; qodo suggested the better one, and it is the right one — RENDER them. _ARM_REZERO is now an f-string evaluated at import against arm_spec.REZERO_ARCS, so the operator standing at the arm gets numbers that are current BY CONSTRUCTION and physically cannot drift from the table. Change the margin or re-measure a wall, and # arm101-cli arm rezero Shift a joint's **encoder zero** — the servo's `Ofs` / `Homing_Offset` register (EEPROM addr 31) — so that the encoder's 4095->0 **seam** falls inside the arc the joint physically cannot reach. **Commands no motion, on any path.** ## The bug this fixes (issue #35) `elbow_flex`'s 12-bit encoder wraps *inside its own physical travel*. 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**: its two measured endpoints sort into a `[min, max]` pair describing exactly the arc it CANNOT reach, and every position comparison in this codebase — `gentle_move`'s arrival check, `clamp_goal`, the reachability map's ranges — is silently wrong for it. It currently rests at raw ~126, i.e. *past* its wrap. Move the seam into the joint's unreachable arc and every tick it can actually reach lies on one side of it. The tick axis is linear again — genuinely, not by assumption. ## 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. 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. ## Which joints Only `elbow_flex`. Every other joint is refused **with the reason** — and there are two different reasons, which the verb keeps apart: - **`wrist_roll` — impossible.** A re-zero only *relocates* a seam; it can never *evict* one. Eviction needs an arc the joint cannot reach, and exploration found no wall anywhere in `wrist_roll`'s travel (measured free range `[21, 4073]`) — it turns freely all the way round, so every angle is reachable, including whichever one the seam is moved to. It is handled instead by a software **soft limit** (`arm_spec.SOFT_LIMITS`), already in force. - **The other four — unnecessary.** Their encoders do not wrap inside their travel, so there is no seam in the way and nothing to evict. ## `--verify` — the seam-eviction proof **Reading the offset back only proves it was APPLIED. It does not prove the seam MOVED.** One undocumented bit of firmware semantics decides which: 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 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). Four verdicts, because "did not fail" is not the same claim as "proved it works": - `seam-evicted` — re-zeroed, continuous, and the sweep actually covered the travel. The fix works. - `seam-not-evicted` — re-zeroed and **still** discontinuous. **STOP.** The re-zero achieves nothing; exit code 2, and the decision goes back to the user. - `seam-present-baseline` — not re-zeroed, discontinuous. The bug, photographed. Expected before the write; not a failure. - `inconclusive` — continuous, but either no offset was in force or the joint was not moved through enough of its travel for "no seam" to mean anything. `--verify` deliberately ends with the joint **limp** — the operator's hand is on it. If the arm is holding a pose it will sag: support it. ## Consent modes Same three-mode gate as `arm flex` / `arm explore` (1-step tier): 1. **TTY (interactive)** — confirm at a prompt. 2. **Non-TTY without `--apply`** — dry-run: prints the exact register writes and opens **no bus at all**. 3. **Non-TTY with `--apply`** — executes. ## Usage arm101-cli arm rezero elbow_flex # dry-run: the exact writes arm101-cli arm rezero elbow_flex --apply # write the offset arm101-cli arm rezero elbow_flex --verify --apply # prove the seam moved arm101-cli arm rezero elbow_flex --verify --duration 45 --apply arm101-cli arm rezero wrist_roll # refused, with the reason arm101-cli arm rezero elbow_flex --json ## After the write — what is NOT yet proven The read-back proves the offset was **applied**, not that it **persists**: PR #21 exists because id/baud EEPROM writes read back correctly and silently reverted on the next power-cycle. **Power-cycle the servo** (cut and restore bus power, not just the serial link), re-read with `arm read`, then run `--verify`. The full hand-run procedure is in `docs/hardware-rezero-procedure.md`. ## Exit codes - `0` success, clean abort, a non-TTY dry-run plan, or an informative sweep (baseline / inconclusive). - `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. ## Hardware / TTY behavior Requires a real motor bus and the Feetech SDK (the `[seeed]` extra). The result and the sweep report go to stdout; the prompt, the live sample feed, and every warning go to stderr. changes with it. Acid test: margin 100 -> explain says (351, 1961); margin 300 -> explain says (551, 1761). It tracks. Also removed the last stale literals: the ~126 rest position (the operator has since moved the arm), the 1900-tick arc width, and the 2196-tick sweep length (the arm's own wall-find measured 2286). 1242 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018vrVNSJ9kzhCQqwpA2e8u6 --- .eidetic/memory/arm101-cli__public.jsonl | 1 + arm101/explain/catalog.py | 41 +++++++++++++++++------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/.eidetic/memory/arm101-cli__public.jsonl b/.eidetic/memory/arm101-cli__public.jsonl index a2a696d..896fd45 100644 --- a/.eidetic/memory/arm101-cli__public.jsonl +++ b/.eidetic/memory/arm101-cli__public.jsonl @@ -15,3 +15,4 @@ {"id": "seam-verification-false-pass-traps", "hash": "fb45432f219a4ecc4067c26ada3e28d37e262c1043db183481fda358c28356a6", "content": "TWO TRAPS THAT WOULD PRODUCE A FALSE PASS when verifying an encoder re-zero (found building t8, PR #40, 2026-07-12). Both matter because the verification is the ONLY thing standing between us and shipping a re-zero that silently did nothing.\n\nTRAP 1 \u2014 THE DISCONTINUITY THRESHOLD MUST BE ~500, NOT THE OBVIOUS 2048. The intuition is `a seam jump is ~4096 ticks, so anything over half that (2048) is a wrap`. WRONG: if the firmware does a plain signed subtraction AND read_position`s `& 0x0FFF` mask folds the resulting negative back into range, the observed seam jump is only ~1949 ticks. A 2048 threshold sails straight past it and reports PASS on a broken fix. Use 500 (sensor noise is a few ticks; there is a huge gap between noise and any real discontinuity).\n\nTRAP 2 \u2014 A SHORT CLEAN SWEEP IS `INCONCLUSIVE`, NEVER A PASS. A hand-sweep covering 200 of 2202 ticks that saw no discontinuity has proven NOTHING \u2014 the seam simply was not in the swept region. Claiming a pass from it closes an open question with a lie. Require >=80% travel coverage before `no discontinuity found` is allowed to mean anything.\n\nGENERAL LESSON: when a verification exists to settle an unproven premise, enumerate how it could report SUCCESS while the premise is false. Both traps here are of that shape.\n\nRELATED DESIGN CALL THAT PAID OFF: rather than assume the favourable semantics, FakeBus models BOTH via `offset_wraps` (True = seam relocates, the premise; False = seam stays pinned, the pessimistic reading). The verb is tested against each, so if hardware settles it the wrong way the pessimistic behaviour is already built and tested, not discovered in production.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "build", "area": "arm101/hardware/rezero.py", "issue": "35", "pr": "40"}, "created": "2026-07-12T10:24:51.190254+00:00", "last_recall": null, "recall_count": 0, "links": ["sts3215-offset-register-facts", "wrist-roll-cannot-be-re-zeroed"], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "arm-explore-rebuild-state-2026-07-12", "hash": "fd81c2c306a1f6a022a691980850c13e8f68535bcafcbaf93c7db3f0c6c2b7b2", "content": "STATE OF THE arm explore REBUILD (2026-07-12, main @ v0.21.0, 1202 tests). Three PRs merged today off the converged spec docs/specs/2026-07-12-arm-explore-now-maps-the-arm-s-real-joint-space-it.md and its 21-task plan:\n\n- PR #38 (v0.19.x) \u2014 CLOSES #33. torque_guard: motion verbs release the arm on any ABNORMAL exit (exception/bus fault/SIGINT). HOLD ON SUCCESS, RELEASE ON ABNORMAL (clean exit = ZERO release writes, so gentle_move stop-and-hold survives).\n- PR #39 (v0.20.x) \u2014 `arm profile `: ramps Goal_Speed and finds the highest speed at which CONTACT DETECTION STILL WORKS (not merely the highest the servo survives). Structurally refuses to certify a speed on free motion alone.\n- PR #40 (v0.21.0) \u2014 encoder linearity (#35 code, NOT yet hardware-verified): `arm rezero` + --verify, offset primitives (addr 31), wrist_roll soft limit + enforcement, and the t5 spike doc.\n\nSTILL OPEN AND WHY:\n- #35 stays OPEN deliberately \u2014 the code shipped but the SEAM-EVICTION PROOF has not run on hardware. docs/hardware-rezero-procedure.md is the procedure; step 6 (torque-off hand-sweep of elbow_flex through its full travel, asserting monotonic position) is what settles whether the servo actually MOVES the seam. If it reports `seam-not-evicted`, the re-zero achieves nothing and wave 2a returns to the user for a re-decision (the ARCS fallback was explicitly NOT chosen).\n- #34 (grid) \u2014 t16 (per-joint bucket sizes from span + target bucket COUNT) is BUILT on branch feat/explore-grid, NOT yet PR`d. t17 (bounds from a supplied map, EEPROM only as fallback) and t18 (honest probe-cost budget + pre-flight wall-clock estimate) are NOT built and are BLOCKED on t15 \u2014 the hardware run of `arm profile` that yields real per-joint speeds.\n- #37 \u2014 does the STS3215 have a comms-loss torque watchdog? Filed because a physical USB yank CANNOT be recovered in software (12V powers the servos; USB is only data).\n\nHUMAN-GATED HARDWARE STILL OWED: t4 (SIGINT + port-contention safety acceptance), t6 (re-confirm elbow_flex wrap BEFORE re-zeroing destroys the evidence), t5-hardware/t11 (the rezero power-cycle + seam sweep), t12/t15 (baseline timing + the arm profile run).", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "session", "area": "arm101", "version": "0.21.0", "issues": "33,34,35,37"}, "created": "2026-07-12T10:51:26.916283+00:00", "last_recall": null, "recall_count": 0, "links": ["wrist-roll-cannot-be-re-zeroed", "sts3215-offset-register-facts", "seam-verification-false-pass-traps", "torque-release-must-use-clear-overload", "usb-yank-cannot-be-fixed-in-software"], "supersedes": null, "lifecycle": "active", "added_by": null}} {"id": "rezero-proven-on-hardware-2026-07-12", "hash": "44eb11b90e77d60071dd52a29fc154808c74a7f85aac3bf462ae0be28e1a2703", "content": "SETTLED ON HARDWARE (2026-07-12, follower /dev/ttyACM1, human present): THE ENCODER RE-ZERO WORKS, and the spike`s open caveat is CLOSED BY MEASUREMENT.\n\nTHE BIG RESULT \u2014 the STS3215 DOES reduce the corrected position MODULO 4096, so the offset genuinely RELOCATES the seam (it does not merely relabel positions). Proven by a torque-off hand sweep of elbow_flex, before/after, same joint same motion:\n Ofs=0 -> monotonic FALSE, 1 discontinuity, span 4093 (the wrap, captured)\n Ofs=1073 -> monotonic TRUE, 0 discontinuities, span 2196, largest jump 28 ticks\nIssue #35 is FIXED for elbow_flex on the physical arm. The pessimistic reading (plain signed subtraction, seam stays pinned) is REFUTED.\n\nFACTORY OFFSET IS 85, NOT 0. All six servos ship with Ofs=+85 at addr 31 (uniform across six joints => a vendor default, not a calibration). The t5 spike ASSUMED factory 0. CONSEQUENCE: every tick ever recorded on this arm was measured in the 85-shifted frame. This is WHY elbow_flex wraps \u2014 the reported seam sits where Actual==Ofs, i.e. at raw 85, which is BELOW the unreachable arc`s lower edge (raw 211) and therefore INSIDE the joint`s travel.\n\nSEMANTICS CONFIRMED TWICE, reversibly: writing Ofs 85->185 dropped reported position by EXACTLY 100; zeroing 85->0 raised it by EXACTLY 85. So Present_Position = Actual - Ofs.\n\nELBOW_FLEX`S FAR WALL, MEASURED FOR THE FIRST TIME (nothing could see across the seam before): travel spans 2196 ticks, reading 1034..3230 in the corrected frame at Ofs=1073. Converting to RAW (raw = corrected + 1073 mod 4096): raw travel = [2107,4095] U [0,207], so the TRUE RAW UNREACHABLE ARC = (207, 2107), width 1900, midpoint 1157. The shipped table said (126,2020)/midpoint 1073 \u2014 those were REPORTED-frame ticks in the 85 frame. 1073 lands inside (207,2107) with 866/1034 margin, so the shipped code WORKED BY LUCK, not by construction.\n\nALSO VALIDATED LIVE: PR #38`s torque_guard fired on real hardware \u2014 a failing `arm rezero` released motor 3 on its way out (`Torque released on motors 3 after an abnormal exit`). And t8`s false-pass guards rejected THREE sweeps as INCONCLUSIVE (0, 0 and 376 ticks of coverage) before the real one \u2014 a naive `no discontinuity seen => PASS` would have declared victory on an empty sweep with the arm untouched.\n\nSTILL UNPROVEN: PERSISTENCE. The offset reads back as 1073 but has NOT survived a power cycle yet \u2014 PR #21 exists because exactly this read back fine and reverted on power-up. Cut and restore the 12V BUS POWER (not USB) and re-read addr 31.\n\nGOTCHA: a read issued immediately after an EEPROM write can return GARBAGE \u2014 a read_position ~0.2s after write_offset returned 0 while the servo genuinely held 3387 (re-reads were stable and correct). A plausible-looking wrong value is worse than an error.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "hardware-run", "area": "arm101/hardware/rezero.py", "issue": "35", "port": "/dev/ttyACM1", "version": "0.21.0", "status": "seam-evicted-persistence-pending"}, "created": "2026-07-12T12:01:22.414446+00:00", "last_recall": null, "recall_count": 0, "links": ["sts3215-offset-register-facts", "seam-verification-false-pass-traps", "wrist-roll-cannot-be-re-zeroed", "encoder-wrap-two-joints"], "supersedes": null, "lifecycle": "active", "added_by": null}} +{"id": "arm-measures-its-own-walls", "hash": "bc99af74a9d79675aa2832f137123d0c5a91c6102345f186f0841ec35c96bf70", "content": "THE ARM MEASURES ITS OWN WALLS BETTER THAN A HUMAN DOES (2026-07-12, follower, PR #41). Once elbow_flex`s axis was linear (post re-zero), gentle_move was driven PAST the known travel and left to find each wall by feel \u2014 creep under torque, watch present_load, stop on contact, back off, hold. The blind-person-in-a-room primitive; the same one `arm explore` uses.\n\n arm flex elbow_flex --to 900 --gentle -> contacted 988, load 500 (SATURATED)\n arm flex elbow_flex --to 3400 --gentle -> contacted 3274, load 500 (SATURATED)\n\nTHE MACHINE BEAT THE HAND ON BOTH SIDES. Hand sweeps gave raw walls 218 / 2107 (and varied 206..218 depending on push force \u2014 a human stops when it FEELS firm). The arm gave 251 / 2061 \u2014 further out AND repeatable, because it presses to a fixed load threshold every time. Load SATURATING at the 500 torque cap is the signature of a real wall, not of an operator`s judgement. LESSON: for anything the arm can measure about its own body, let the arm measure it.\n\nTHE SECOND MOVE IS THE PROOF OF THE WHOLE RE-ZERO: commanding corrected 3400 CROSSES the raw 4095->0 boundary, and in the corrected frame it is a plain linear climb that converged. That exact command would have rotated the elbow THE LONG WAY ROUND into a wall before the re-zero.\n\nDERIVED CONSTANT DISCIPLINE (the durable lesson): the arc table exists to be RE-MEASURED, but ~125 arc-coupled literals were hard-coded across two test files, so re-measuring it broke 34 tests. Tests now DERIVE every expectation from arm_spec (REZERO_ARCS, .midpoint, .evicts, _ARC_MARGIN_TICKS). ACID TEST: change the margin or either wall -> suite stays green. Same rule applied to prose: docstrings and the explain catalog no longer quote ticks AT ALL \u2014 a doc that names a measurement is a doc that goes stale, and `explain arm rezero` prints to whoever is standing at the arm.\n\nARC MARGIN IS LOAD-BEARING: a first cut declared the arc AT the extremes of one hand sweep and FALSE-REFUSED within minutes (the joint came to rest 11 ticks past an edge the operator had simply stopped short of). Declare the arc as a STRICT SUBSET of the unreachable region, inset on both sides. Conservative in both directions: cannot false-refuse a legal position, and cannot claim a tick the joint can actually reach. NOTE the operator had to move the gripper aside `or we would hit the table` \u2014 so a wall may be ENVIRONMENTAL, not mechanical, which makes true travel WIDER and the true arc NARROWER. Margin absorbs that too. This is issue #34`s `the table is the wall` arriving in a new place.\n\nOPERATOR INSIGHT WORTH KEEPING: `there is the max of each joint, but also the max of a certain setup/joined movement`. That IS issue #34 \u2014 single-joint range vs COMBINATION reachability \u2014 and it remains unanswered until the grid lands.", "scope": {"name": "arm101-cli", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "hardware-run", "area": "arm101/hardware/arm_spec.py", "issue": "35", "pr": "41", "version": "0.22.0"}, "created": "2026-07-12T13:14:07.753531+00:00", "last_recall": null, "recall_count": 0, "links": ["rezero-proven-on-hardware-2026-07-12", "sts3215-offset-register-facts", "arm-explore-grid-is-structurally-wrong"], "supersedes": null, "lifecycle": "active", "added_by": null}} diff --git a/arm101/explain/catalog.py b/arm101/explain/catalog.py index d1525c6..18a77ae 100644 --- a/arm101/explain/catalog.py +++ b/arm101/explain/catalog.py @@ -12,6 +12,8 @@ from __future__ import annotations +from arm101.hardware import arm_spec + _ROOT = """\ # arm101-cli @@ -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 Shift a joint's **encoder zero** — the servo's `Ofs` / `Homing_Offset` register @@ -885,21 +894,31 @@ - **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**. The live numbers are in that table and -nowhere else — they are re-measured on hardware, so this text does not repeat -them (a doc that names a tick is a doc that goes stale). The arc comes from -a torque-off hand sweep that measured its travel at 2196 ticks — raw -`[2107, 4095] ∪ [0, 207]`, which *wraps*, which is exactly the fact a `[min, max]` -pair cannot express. Every live reading is converted (`raw = (reported + offset) -mod 4096`) before it is compared against it. +`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 +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 1900-tick arc the joint +*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 @@ -947,7 +966,7 @@ **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 all 2196 ticks. No +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.